An Iceberg destination for DLT that supports REST catalogs
dlt-iceberg
A dlt destination for Apache Iceberg tables using REST catalogs.
Features
- Atomic Multi-File Commits: Multiple parquet files committed as single Iceberg snapshot per table
- REST Catalog Support: Works with Nessie, Polaris, AWS Glue, Unity Catalog
- Credential Vending: Most REST catalogs vend storage credentials automatically
- Partitioning: Full support for Iceberg partition transforms via
iceberg_adapter() - Merge Strategies: Delete-insert and upsert with hard delete support
- DuckDB Integration: Query loaded data via
pipeline.dataset() - Schema Evolution: Automatic schema updates when adding columns
Installation
pip install dlt-iceberg
Or with uv:
uv add dlt-iceberg
Quick Start
import dlt
from dlticeberg import icebergrest
@dlt.resource(name="events", write_disposition="append") def generate_events(): yield {"event_id": 1, "value": 100}
pipeline = dlt.pipeline( pipelinename="mypipeline", destination=iceberg_rest( catalog_uri="https://my-catalog.example.com/api/catalog", namespace="analytics", warehouse="my_warehouse", credential="client-id:client-secret", oauth2serveruri="https://my-catalog.example.com/oauth/tokens", ), )
pipeline.run(generate_events())
Query Loaded Data
# Query data via DuckDB
dataset = pipeline.dataset()
Access as dataframe
df = dataset["events"].df()
Run SQL queries
result = dataset.query("SELECT * FROM events WHERE value > 50").fetchall()
Get Arrow table
arrow_table = dataset["events"].arrow()
Merge/Upsert
@dlt.resource(
name="users",
write_disposition="merge",
primarykey="userid"
)
def generate_users():
yield {"user_id": 1, "name": "Alice", "status": "active"}
pipeline.run(generate_users())
Configuration
Required Options
iceberg_rest(
catalog_uri="...", # REST catalog endpoint (or sqlite:// for local)
namespace="...", # Iceberg namespace (database)
)
Authentication
Choose based on your catalog:
| Catalog | Auth Method | |---------|-------------| | Polaris, Lakekeeper | credential + oauth2serveruri | | Unity Catalog | token | | AWS Glue | sigv4enabled + signingregion | | Local SQLite | None needed |
Most REST catalogs (Polaris, Lakekeeper, etc.) vend storage credentials automatically via the catalog API. You typically don't need to configure S3/GCS/Azure credentials manually.
Advanced Options
iceberg_rest(
# ... required options ...
# Manual storage credentials (usually not needed with credential vending) s3_endpoint="...", s3accesskey_id="...", s3secretaccess_key="...", s3_region="...",
# Performance tuning max_retries=5, # Retry attempts for transient failures retrybackoffbase=2.0, # Exponential backoff multiplier mergebatchsize=500000, # Rows per batch for merge operations strict_casting=False, # Fail on potential data loss
# Table management tablelocationlayout=None, # Custom table location pattern registernewtables=False, # Register tables found in storage harddeletecolumn="dltdeleted_at", # Column for hard deletes )
Catalog Examples
Lakekeeper (Docker)
iceberg_rest(
catalog_uri="http://localhost:8282/catalog/",
warehouse="test-warehouse",
namespace="my_namespace",
s3_endpoint="http://localhost:9000",
s3accesskey_id="minioadmin",
s3secretaccess_key="minioadmin",
s3_region="us-east-1",
)
Start Lakekeeper + MinIO with docker compose up -d. Lakekeeper supports credential vending in production.
Polaris
iceberg_rest(
catalog_uri="https://polaris.example.com/api/catalog",
warehouse="my_warehouse",
namespace="production",
credential="client-id:client-secret",
oauth2serveruri="https://polaris.example.com/api/catalog/v1/oauth/tokens",
)
Storage credentials are vended automatically by the catalog.
Unity Catalog (Databricks)
iceberg_rest(
catalog_uri="https://<workspace>.cloud.databricks.com/api/2.1/unity-catalog/iceberg-rest",
warehouse="<catalog-name>",
namespace="<schema-name>",
token="<databricks-token>",
)
AWS Glue
iceberg_rest(
catalog_uri="https://glue.us-east-1.amazonaws.com/iceberg",
warehouse="<account-id>:s3tablescatalog/<bucket>",
namespace="my_database",
sigv4_enabled=True,
signing_region="us-east-1",
)
Requires AWS credentials in environment (AWSACCESSKEYID, AWSSECRETACCESSKEY).
Local SQLite Catalog
iceberg_rest(
catalog_uri="sqlite:///catalog.db",
warehouse="file:///path/to/warehouse",
namespace="my_namespace",
)
Great for local development and testing.
Nessie (Docker)
iceberg_rest(
catalog_uri="http://localhost:19120/iceberg/main",
namespace="my_namespace",
s3_endpoint="http://localhost:9000",
s3accesskey_id="minioadmin",
s3secretaccess_key="minioadmin",
s3_region="us-east-1",
)
Start Nessie + MinIO with docker compose up -d (see docker-compose.yml in repo).
Partitioning
Using iceberg_adapter (Recommended)
The iceberg_adapter function provides a clean API for configuring Iceberg partitioning:
from dlticeberg import icebergadapter, iceberg_partition
@dlt.resource(name="events") def events(): yield {"eventdate": "2024-01-01", "userid": 123, "region": "US"}
Single partition
adapted = iceberg_adapter(events, partition="region")
Multiple partitions with transforms
adapted = iceberg_adapter(
events,
partition=[
icebergpartition.month("eventdate"),
icebergpartition.bucket(10, "userid"),
"region", # identity partition
]
)
pipeline.run(adapted)
Partition Transforms
# Temporal transforms (for timestamp/date columns)
icebergpartition.year("createdat")
icebergpartition.month("createdat")
icebergpartition.day("createdat")
icebergpartition.hour("createdat")
Identity (no transformation)
iceberg_partition.identity("region")
Bucket (hash into N buckets)
icebergpartition.bucket(10, "userid")
Truncate (truncate to width)
iceberg_partition.truncate(4, "email")
Custom partition field names
icebergpartition.month("createdat", "event_month")
icebergpartition.bucket(8, "userid", "user_bucket")
Using Column Hints
Prefer iceberg_adapter for partitioning. If you need to set column hints directly, use the x-partition custom hints; raw partition_transform fields are rejected by dlt schema validation.
@dlt.resource(
name="events",
columns={
"event_date": {
"data_type": "date",
"x-partition": True,
"x-partition-transform": "day",
},
"user_id": {
"data_type": "bigint",
"x-partition": True,
"x-partition-transform": "bucket[10]",
}
}
)
def events():
...
Write Dispositions
Append
write_disposition="append"
Adds new data without modifying existing rows.
Replace
write_disposition="replace"
Truncates table and inserts new data.
Merge
Delete-Insert Strategy (Default)
@dlt.resource(
write_disposition={"disposition": "merge", "strategy": "delete-insert"},
primarykey="userid"
)
Deletes matching rows then inserts new data. Single atomic transaction.
Upsert Strategy
@dlt.resource(
write_disposition={"disposition": "merge", "strategy": "upsert"},
primarykey="userid"
)
Updates existing rows, inserts new rows.
Hard Deletes
Mark rows for deletion by setting the dltdeleted_at column:
@dlt.resource(
write_disposition={"disposition": "merge", "strategy": "delete-insert"},
primarykey="userid"
)
def userswithdeletes():
from datetime import datetime
yield {"userid": 1, "name": "alice", "dltdeletedat": None} # Keep
yield {"userid": 2, "name": "bob", "dltdeletedat": datetime.now()} # Delete
Development
Run Tests
# Start Docker services (for Nessie tests)
docker compose up -d
Run all tests
uv run pytest tests/ -v
Run only unit tests (no Docker required)
uv run pytest tests/ --ignore=tests/nessie -v
Run Nessie integration tests
uv run pytest tests/nessie/ -v
Project Structure
dlt-iceberg/
โโโ src/dlt_iceberg/
โ โโโ init.py # Public API
โ โโโ destination_client.py # Class-based destination (atomic commits)
โ โโโ destination.py # Function-based destination (legacy)
โ โโโ adapter.py # iceberg_adapter() for partitioning
โ โโโ sql_client.py # DuckDB integration for dataset()
โ โโโ schema_converter.py # dlt โ Iceberg schema conversion
โ โโโ schema_casting.py # Arrow table casting
โ โโโ schema_evolution.py # Schema updates
โ โโโ partition_builder.py # Partition specs
โ โโโ error_handling.py # Retry logic
โโโ tests/
โ โโโ testadapter.py # icebergadapter tests
โ โโโ test_capabilities.py # Hard delete, partition names tests
โ โโโ test_dataset.py # DuckDB integration tests
โ โโโ testmergedisposition.py
โ โโโ testschemaevolution.py
โ โโโ ...
โโโ examples/
โ โโโ incremental_load.py # CSV incremental loading
โ โโโ merge_load.py # CSV merge/upsert
โ โโโ data/ # Sample CSV files
โโโ docker-compose.yml # Nessie + MinIO for testing
How It Works
The class-based destination uses dlt's JobClientBase interface to accumulate parquet files during a load and commit them atomically in complete_load():
- dlt extracts data and writes parquet files
- Each file is registered in module-level global state
- After all files complete,
complete_load()is called - All files for a table are combined and committed as single Iceberg snapshot
- Each table gets one snapshot per load
License
MIT License - see LICENSE file