Batch ETL: S3 Parquet + Iceberg
This tutorial demonstrates how to build a batch ETL pipeline using Ontul with Apache Iceberg and S3 (MinIO).
What you will learn:
- Write in-memory data to S3 as Parquet
- Read Parquet files from S3
- Load data into Iceberg tables
- Use the DataFrame API: filter, select, join, groupBy, agg, orderBy
- Execute DDL/DML: CTAS, INSERT INTO, MERGE INTO
- Run cross-catalog federation queries (Iceberg + TPC-H)
Prerequisites
- Java 17
- Docker (for MinIO + Polaris)
1. Install Ontul
Follow the Installation Guide to download and set up Ontul, then:
2. Start Infrastructure
Start MinIO (S3-compatible storage), Apache Polaris (Iceberg REST catalog), and Kafka:
This starts:
| Service | Port | Description |
|---|---|---|
| MinIO | 9000 (API), 9001 (Console) | S3-compatible object storage |
| Polaris | 8181 | Iceberg REST catalog |
| Kafka | 9092 | Message broker (for streaming use case) |
Wait for all services to be healthy:
3. Start Ontul Cluster and Register Catalogs
This script:
- Starts the Ontul cluster
- Logs in and changes the default password
- Registers
tpchcatalog (built-in TPC-H benchmark data) - Registers
icecatalog (Iceberg via Polaris + MinIO)
After setup, the ONTUL_USER_TOKEN environment variable is exported.
4. Run the Batch ETL Example
Example Code (Java)
The full example is in examples/java/IcebergBatchExample.java.
Step 1: Write Data to S3 as Parquet
Create in-memory product data and write it to S3:
OntulSession session = OntulSession.builder()
.master("localhost", 47470)
.build();
var products = List.of(
Map.<String, Object>of("product_id", 1, "name", "Laptop", "category", "Electronics", "price", 1299.99, "stock", 50),
Map.<String, Object>of("product_id", 2, "name", "Mouse", "category", "Electronics", "price", 29.99, "stock", 200),
// ... more products
);
session.source(Source.data(products))
.sink(Sink.s3("s3://iceberg-warehouse/raw/products", "parquet")
.property("endpoint", "http://localhost:9000")
.property("accessKey", "minioadmin")
.property("secretKey", "minioadmin")
.property("pathStyle", "true"));
Step 2: Read Parquet from S3
var s3Products = session.source(Source.s3("s3://iceberg-warehouse/raw/products", "parquet")
.property("endpoint", "http://localhost:9000")
.property("accessKey", "minioadmin")
.property("secretKey", "minioadmin")
.property("pathStyle", "true"))
.select("product_id", "name", "category", "price", "stock");
Step 3: DataFrame Chain (filter, select, orderBy)
var expensiveElectronics = session.source(Source.s3("s3://iceberg-warehouse/raw/products", "parquet")
.property("endpoint", "http://localhost:9000")
.property("accessKey", "minioadmin")
.property("secretKey", "minioadmin")
.property("pathStyle", "true"))
.filter("category = 'Electronics'")
.filter("price > 100")
.select("name", "price", "stock")
.orderBy("price", false);
Step 4: Load into Iceberg Table
Write the S3 Parquet data into an Iceberg table managed by Polaris:
session.source(Source.s3("s3://iceberg-warehouse/raw/products", "parquet")
.property("endpoint", "http://localhost:9000")
.property("accessKey", "minioadmin")
.property("secretKey", "minioadmin")
.property("pathStyle", "true"))
.sink(Sink.table("ice.examples.products"));
Step 5: CTAS (Create Table As Select)
Create Iceberg tables from TPC-H benchmark data:
session.execute("CREATE TABLE ice.examples.nations AS SELECT * FROM tpch.tiny.nation");
session.execute(
"CREATE TABLE ice.examples.orders AS " +
"SELECT o_orderkey, o_custkey, o_totalprice, o_orderstatus, o_orderdate " +
"FROM tpch.tiny.orders LIMIT 200");
Step 6: GroupBy + Aggregation on Iceberg
var categorySummary = session.source(Source.sql("SELECT * FROM ice.examples.products"))
.groupBy("category")
.agg("COUNT(*) AS product_count, CAST(AVG(price) AS DECIMAL(10,2)) AS avg_price, SUM(stock) AS total_stock")
.orderBy("product_count", false);
Step 7: Cross-Catalog JOIN (Iceberg + TPC-H)
Query Iceberg orders joined with TPC-H customer data in a single query:
var orders = session.source(Source.sql("SELECT * FROM ice.examples.orders"));
var customers = session.source(Source.sql("SELECT * FROM tpch.tiny.customer"));
var topCustomers = orders
.join(customers, "l.o_custkey = r.c_custkey")
.groupBy("c_name")
.agg("SUM(o_totalprice) AS total_spent")
.orderBy("total_spent", false)
.limit(5);
Step 8: INSERT INTO and MERGE INTO
// Append more orders
session.execute(
"INSERT INTO ice.examples.orders " +
"SELECT o_orderkey, o_custkey, o_totalprice, o_orderstatus " +
"FROM tpch.tiny.orders LIMIT 100");
// Upsert nations
session.execute(
"MERGE INTO ice.examples.nations AS t " +
"USING (SELECT * FROM tpch.tiny.nation WHERE n_regionkey = 0) AS s " +
"ON t.n_nationkey = s.n_nationkey " +
"WHEN MATCHED THEN UPDATE SET n_comment = 'updated' " +
"WHEN NOT MATCHED THEN INSERT (n_nationkey, n_name, n_regionkey, n_comment) " +
"VALUES (s.n_nationkey, s.n_name, s.n_regionkey, s.n_comment)");
Python Version
The same example is available in Python: examples/python/iceberg_batch_example.py
from ontul.session import OntulSession
session = OntulSession()
# Write to S3
session.sink_s3(products, "s3://iceberg-warehouse/raw/products-py", "parquet", s3_config)
# Read from S3
rows = session.source_s3("s3://iceberg-warehouse/raw/products-py", "parquet", s3_config)
# Sink to Iceberg
session.sink(pa.Table.from_pandas(df), "ice.examples.products_py")
# CTAS
session.execute("CREATE TABLE ice.examples.nations_py AS SELECT * FROM tpch.tiny.nation")
# Cross-catalog JOIN (via RemoteDataFrame)
orders = source(session, "ice.examples.orders_py")
customers = source(session, "tpch.tiny.customer")
top = (orders.join(customers, "l.o_custkey = r.c_custkey")
.group_by("c_name")
.agg("SUM(o_totalprice) AS total_spent")
.order_by("total_spent", ascending=False)
.limit(5))
print(top.to_pandas())