A DataFrame-like library for working with Apache Iceberg tables using REST catalogs with local execution.
IceFrame provides a simple, intuitive API for creating, reading, updating, and deleting Iceberg tables, as well as performing maintenance operations and exporting data.
Upgrading from 0.12?0.13.0 fixes silent data loss and silently wrong query results.
compact_data_files(filter_expr=...)used to replace the whole table with the filtered subset; a compound filter whose operand couldn't be pushed down had that operand silently dropped; null rows passed every data-quality constraint;expire_snapshotscalled a PyIceberg API that doesn't exist; andcreate_table(sort_order=["col"])raisedAttributeError. All are fixed with regression tests. SeeCHANGELOG.mdfor the full list and behaviour changes.
- DataFrame API: Familiar interface for working with tables —
read_table,to_arrow,to_pandas,lazy,head,describe,count_rows,scan_batches - Local Execution: Uses PyIceberg, PyArrow, and Polars for efficient local processing
- Catalog Support: REST catalogs (Dremio, Tabular, Polaris, …) with credential vending, plus PyIceberg's
sql(SQLite/Postgres),memory,glue,hive, anddynamodbcatalogs by pass-through - CRUD Operations: Create, Read, Update, Delete tables and data
- Native upserts and transactions:
ice.upsert(...)on PyIceberg's atomicTable.upsert, andwith ice.transaction(tbl) as txn:for multi-operation atomic commits - Predicate, projection and limit pushdown: the query builder pushes
WHERE,SELECTandLIMITinto the Iceberg scan when it is sound to do so, and re-applies anything it cannot push locally so results stay correct - Metadata tables:
ice.inspect(tbl).snapshots() / .files() / .partitions() / .manifests() / .history() / .refs()as Polars frames - Maintenance: Expire snapshots, remove orphan files (dry-run by default), compact data files (bin-pack, sort, approximate z-order)
- Data quality: constraint validation where nulls fail by default, wired into
append_to_table(validators=[...])as a write gate - Typed: ships
py.typed, so annotations are visible to downstream type checkers - Structured logging: standard
loggingthroughout the library; noprint()to stdout - Export: Export data to Parquet, CSV, and JSON
- Creating Tables
- Reading Tables
- Updating Tables
- Deleting Tables
- CLI Usage
- Dependencies
- Environment Variables
- Native File Ingestion (CSV, JSON, Parquet, ORC, Avro)
- Optional File Ingestion (Excel, Delta, Google Sheets)
- Advanced File Ingestion (SQL, XML, SAS/SPSS)
- API Ingestion
- HuggingFace Ingestion
- HTML Ingestion
- Clipboard Ingestion
- Folder Ingestion
- Bulk Ingestion
- Incremental Ingestion
- Query Builder API
- SQL Support (DataFusion)
- Lazy Reading
- Distributed Processing (Ray)
- Async Operations
- Notebook Integration
- Scalable Updates
- Namespace Management
- Schema Evolution
- Partition Management
- Branching & Tagging
- Views
- Catalog Operations
- Catalog Support Matrix
- Transactions & Upserts
- Metadata Tables
- Table Maintenance
- Native Maintenance
- Safe Compaction
- Streaming Auto-Compaction
- Data Quality
- Enhanced Data Quality
- Rollback & History
- Visualization
- Incremental Processing
- Table Statistics
- Scalability Overview
- AI Agent
- MCP Server
- Pydantic Integration
pip install iceframeFor cloud storage support:
pip install "iceframe[aws]"# AWS S3
pip install "iceframe[gcs]"# Google Cloud Storage
pip install "iceframe[azure]"# Azure Data Lake Storage- Create a
.envfile with your catalog credentials (see.env.example):
ICEBERG_CATALOG_URI=https://catalog.dremio.cloud/api/icebergICEBERG_TOKEN=your_tokenICEBERG_WAREHOUSE=your_warehouseICEBERG_CATALOG_TYPE=rest- Use IceFrame in your code:
fromiceframeimportIceFrame, col, lit, load_catalog_config_from_envimportpolarsaspl# Initializeconfig=load_catalog_config_from_env()
ice=IceFrame(config)
# Create an EMPTY table (note: as of 0.12, create_table never writes data# even if you pass a DataFrame as the schema — use append_to_table afterwards)schema= {
"id": "long",
"name": "string",
"created_at": "timestamp",
}
ice.create_table("my_table", schema)
# Append datadata=pl.DataFrame({
"id": [1, 2],
"name": ["Alice", "Bob"],
"created_at": [pl.datetime(2024, 1, 1), pl.datetime(2024, 1, 2)],
})
ice.append_to_table("my_table", data)
# Read datadf=ice.read_table("my_table")
print(df)
# Query Builder API — col, lit, IceFrame, QueryBuilder all importable from# the package root as of 0.12.fromiceframe.functionsimportsumasice_sumdf= (ice.query("my_table")
.select("name", ice_sum(col("id")).alias("total_id"))
.group_by("name")
.execute())
print(df)filter_expr= historically accepted both an IceFrame Expression (pushed to
Iceberg) and a string (Polars SQL, evaluated locally) — the same parameter name
meaning two languages with opposite performance. As of 0.13.0 both are named:
# Pushed into the Iceberg scan: only matching files are read.ice.read_table("my_table", filter=col("age") >30)
# Evaluated locally after the scan, in Polars SQL.ice.read_table("my_table", filter_sql="age > 30")filter_expr= still works and dispatches on type, but the explicit parameters
are preferred.
# `selected_fields=("id",)` and `limit=10` are pushed into the scan, so a# billion-row table is not materialised just to trim it.ice.query("events").select("id").limit(10).execute()Pushdown is skipped when it would change the answer — an ORDER BY, a join, an
aggregation, or a predicate that can't be fully pushed all force the limit to be
applied after the fact.
# Atomic, incremental MERGE — only files containing matching rows are rewritten.ice.upsert("users", new_rows, join_cols=["id"])
# Schema change + append in a single commit.withice.transaction("events") astxn:
txn.set_properties({"owner": "data-eng"})
txn.append(arrow_table)ice.inspect("events").snapshots() # Polars DataFrameice.inspect("events").files()
ice.inspect("events").partitions()# Compaction scoped by a filter rewrites ONLY matching rows. Before 0.13.0 this# replaced the entire table with the matching subset.ice.compact_data_files("events", filter_expr="event_date >= '2026-01-01'")
# Snapshot expiry, on PyIceberg's native maintenance API. Branch/tag heads are# always protected.ice.expire_snapshots("events", older_than_days=30, retain_last=5)
# Orphan-file cleanup is DRY-RUN BY DEFAULT: it deletes files permanently.candidates=ice.remove_orphan_files("events", older_than_days=7)
ice.remove_orphan_files("events", older_than_days=7, dry_run=False)fromiceframe.qualityimportDataValidator# A null `age` FAILS "age > 0". Before 0.13.0 it passed, because Polars'# three-valued logic filtered null rows out of the violations frame.DataValidator().validate(df, ["age > 0"])
DataValidator().validate(df, ["age > 0"], null_policy="pass") # opt out# As a write gate:ice.append_to_table("users", df, validators=["age > 0"]) # raises ValidationErrorEvery deliberate IceFrame error derives from IceFrameError, so infrastructure
failures are distinguishable from bad input. For backwards compatibility the
input-shaped errors are still ValueError subclasses:
fromiceframeimportIceFrameError, CatalogError, ValidationErrortry:
ice.append_to_table("users", df, validators=["age > 0"])
exceptValidationError:
... # your data was badexceptCatalogError:
... # the catalog was unreachablepip install -e ".[dev]"
pytest # runs offline against a local SQLite catalog
pytest --live # additionally runs the live REST-catalog tests
ruff check iceframe/ tests/The core suite needs no credentials and no network: a session-scoped
fixture builds a PyIceberg sql (SQLite) catalog with a file:// warehouse.
Before 0.13.0 the read, write, query-builder, schema and stats tests only ran
against the author's live Dremio catalog, so 61 of 179 tests skipped everywhere
else.
IceFrame builds on top of PyIceberg, adding high-level abstractions and missing features.
| Feature | PyIceberg (Native) | IceFrame (Enhanced) |
|---|---|---|
| Table CRUD | Low-level API | Simplified create_table, drop_table |
| Data Writing | Arrow/Pandas integration | Polars integration, Auto-schema inference |
| Branching | Basic support (WIP) | create_branch, fast_forward, WAP Pattern |
| Compaction | rewrite_data_files (limited) | bin_pack, sort strategies (Polars-based) |
| Views | Catalog-dependent | Unified ViewManager abstraction |
| Maintenance | expire_snapshots | GarbageCollector, Nativeremove_orphan_files |
| SQL Support | None | Fluent Query Builder (select, filter, join) |
| Ingestion | add_files | add_files wrapper + Incremental Ingestion recipes |
| Rollback | manage_snapshots | rollback_to_snapshot, rollback_to_timestamp |
| Async | None | AsyncIceFrame with its own bounded thread pool |
| Upsert / MERGE | Table.upsert | ice.upsert(...) wrapper + QueryBuilder.merge for column-level update rules |
| Transactions | Table.transaction() | with ice.transaction(tbl) as txn: |
| Metadata tables | Table.inspect.* (Arrow) | ice.inspect(tbl).* returning Polars frames |
| Data Quality | None | DataValidator constraints, null-failing by default, usable as a write gate |
| Agent surface | None | Read-only MCP server with row/byte caps, plus a multi-provider LLM agent |
Being straight about the limits:
- Merge-on-read delete writes are not supported. PyIceberg 0.11 has no public delete-file writer, so
MoRWriter.write_position_deletes/write_equality_deletesraiseUnsupportedOperationError. Deletes are copy-on-write. Reading tables that already contain delete files works fine. QueryBuilder.mergewith column-level update rules reads the whole target table and overwrites it. Useice.upsert(...)(native, incremental, atomic) whenever "replace matched rows wholesale" is what you want.- Joins read each joined table in full. Only the driving table gets pushdown.
- Z-order is an approximation — a hierarchical sort, not a bit-interleaved Z-curve. The returned
strategysays so. - Window functions run locally in Polars, after the scan.