Introduction
directly. From the configuration side an Iceberg target is just another database connection: point Sling at the catalog URL and the underlying object store, then declare your streams. No JVM, no Spark, no manual manifest writing.
This guide replicates a Postgres schema into Iceberg using and the storage layer underneath is R2. Every CLI line, row count, and timing below comes from an actual run against those endpoints.
Installing Sling
Sling is a single binary. Pick whichever install fits:
# macOS / Linux
curl -fsSL https://slingdata.io/install.sh | bash
# Windows
irm https://slingdata.io/install.ps1 | iex
# Python
pip install sling
Confirm:
sling --version
Full install notes are in the cover SSL, IAM, and the rest.
Configuring the Iceberg Target
Sling treats Iceberg as a database-class target. The connection captures two things: the catalog, which stores table metadata, and the warehouse, which stores the actual Parquet data files. Sling supports REST, AWS Glue, and SQL catalogs. This guide uses REST.
For Cloudflare R2's Iceberg catalog you need the catalog URL, an API token, the warehouse identifier (account-id + bucket name), and S3-compatible credentials for the R2 bucket underneath. All four come from the R2 dashboard.
connections:
ICEBERG:
type: iceberg
catalog_type: rest
rest_uri: https://catalog.cloudflarestorage.com/<accountid>/<bucket>
rest_token: <r2_catalog_api_token>
rest_warehouse: <accountid>_<bucket>
s3_access_key_id: <r2_access_key_id>
s3_secret_access_key: <r2_secret_access_key>
For a self-hosted catalog, the shape is the same; only the rest_uri and rest_warehouse change. For AWS Glue, set catalog_type: glue and glue_warehouse: s3://my-bucket/warehouse. The . Sling substitutes the source table name so you don't repeat yourself.
mode: incremental with an update_key. That's the only diff between a one-shot bulk load and an ongoing append flow.Run it:
sling run -r replication.yaml
Real output, trimmed:
INF Sling CLI | https://slingdata.io
WRN for mode 'incremental' with iceberg target, primary-key is ineffective,
incremental merge is not yet supported (only appends)
INF Sling Replication [3 streams] | POSTGRES -> ICEBERG
INF [1 / 3] running stream demo_postgres_iceberg.users
INF created table "demo_postgres_iceberg"."users"
INF streaming data (direct insert)
INF inserted 8000 rows into "demo_postgres_iceberg"."users" in 11 secs [713 r/s] [519 kB]
INF [2 / 3] running stream demo_postgres_iceberg.orders
INF created table "demo_postgres_iceberg"."orders"
INF inserted 35000 rows into "demo_postgres_iceberg"."orders" in 9 secs [3,721 r/s] [2.1 MB]
INF [3 / 3] running stream demo_postgres_iceberg.events
INF getting checkpoint value (occurred_at)
INF writing to target database [mode: incremental]
INF created table "demo_postgres_iceberg"."events"
INF inserted 60000 rows into "demo_postgres_iceberg"."events" in 7 secs [8,190 r/s] [4.5 MB]
INF Sling Replication Completed in 29s | POSTGRES -> ICEBERG | 3 Successes | 0 Failures
103,000 rows across three tables, 29 seconds end-to-end. The warning at the top deserves a real answer; see the section on incremental modes further down.
Verification
Sling can query Iceberg tables directly through its DuckDB-backed reader. Tables are addressed as iceberg_catalog.<namespace>.<table>:
sling conns exec ICEBERG \
"select 'users' as t, count(*) as c
from iceberg_catalog.demo_postgres_iceberg.users
union all
select 'orders', count(*) from iceberg_catalog.demo_postgres_iceberg.orders
union all
select 'events', count(*) from iceberg_catalog.demo_postgres_iceberg.events"
+--------+-------+
| T | C |
+--------+-------+
| users | 8000 |
| orders | 35000 |
| events | 60000 |
+--------+-------+
Row counts match the source. A sample of users confirms columns and types survived the trip:
sling conns exec ICEBERG \
"select user_id, email, country, signup_at
from iceberg_catalog.demo_postgres_iceberg.users
order by user_id limit 5"
+---------+-------------------+---------+-------------------------------+
| USER_ID | EMAIL | COUNTRY | SIGNUP_AT |
+---------+-------------------+---------+-------------------------------+
| 1 | [email protected] | BR | 2025-01-01 00:14:00 -0300 -03 |
| 2 | [email protected] | DE | 2025-01-01 00:28:00 -0300 -03 |
| 3 | [email protected] | FR | 2025-01-01 00:42:00 -0300 -03 |
| 4 | [email protected] | JP | 2025-01-01 00:56:00 -0300 -03 |
| 5 | [email protected] | UK | 2025-01-01 01:10:00 -0300 -03 |
+---------+-------------------+---------+-------------------------------+
Postgres jsonb lands as a structured column too. Sampling events:
+----------+---------+------------+----------------------+----------------------+
| EVENT_ID | USER_ID | EVENT_TYPE | PAYLOAD | OCCURRED_AT |
+----------+---------+------------+----------------------+----------------------+
| 60001 | 2 | click | {"v": 1, "utm": "x"} | 2026-05-11 ... |
| 60002 | 3 | signup | {"v": 2, "utm": "x"} | 2026-05-11 ... |
| 60003 | 4 | purchase | {"v": 3, "utm": "x"} | 2026-05-11 ... |
+----------+---------+------------+----------------------+----------------------+
Any other Iceberg reader sees the same data: DuckDB with the iceberg extension, Spark, Trino, Athena, Snowflake's catalog-linked databases. That portability is the reason for the catalog in the first place.
Running an Incremental Append
After the bulk load, the day-to-day shape is: every few minutes (or hours, or once a day), pick up the new rows since the last run and append them to the Iceberg table. Sling's for when full merge mode ships.
Common tweaks
Choose the right catalog. REST is the most portable: the same connection shape works for Cloudflare R2, Lakekeeper, Nessie, Polaris, and any other REST-compatible catalog. Glue is the simplest in AWS-native shops. SQL catalog is fine for local dev. Avoid wiring a different catalog per environment if you can help it; the table layout doesn't care, but the metadata location does.
Namespace organization. Treat namespaces (demo_postgres_iceberg.users) the way you treat warehouse schemas: one per source system, or one per data domain. Don't dump everything intodefault.
Filter at the source. Use asql:block per stream to project columns or filter rows before they leave Postgres. Smaller Parquet files, smaller manifests, cheaper queries downstream.
Time travel for free. Every replication produces a new Iceberg snapshot. Readers can time-travel to a previous snapshot, which is useful for "what did this table look like before yesterday's run?" without storing your own backups.
Maintain the table. Like any Iceberg table, periodic compaction and snapshot expiration keep the file count and metadata size from growing without bound. Set this up on a separate schedule from the replication itself.
Where to go next
The same pattern works for any of walkthrough shows the same source landing as raw Parquet files instead of an Iceberg table, which is useful when downstream readers don't need a catalog. For a deeper comparison of file-format targets, see .
For team workflows with scheduling, alerting, and audit trails on top of the same CLI, look at the or GitHub Issues.
SOCIAL SHARE CARD GENERATOR