CDC to Apache Iceberg on Amazon S3
Change Data Capture on SQL Server, Apache Iceberg tables and catalog written directly to S3 with deletes applied, read by Snowflake, Athena or any Iceberg engine.
Use this scenario when your DBAs already run Change Data Capture (CDC), or when you need the before and after images and operation metadata that CDC records, and when Apache Iceberg is the table format your analytics platform standardises on. The agent writes Iceberg data files, metadata and its catalog straight to S3.
When to use it
- CDC is enabled on the source (it requires the SQL Server Agent service to run the capture and cleanup jobs; Change Tracking does not). If you have a free choice, Change Tracking is simpler to operate; see Data retrieval.
- Downstream engines read Iceberg natively: Snowflake, Athena, Trino, Spark, DuckDB.
- Deletes at the source must disappear from the target (
iceberg_apply_deletes: true), or be kept as flagged rows (ct_cdc_delete_mode: logical).
Prerequisites
- A DLH.io account and API key, with the connection's storage provider set to S3 so the platform issues S3 credentials at runtime. Static keys are also supported; see the variation at the end.
- CDC enabled on the database and each table:
USE OrdersDb;
EXEC sys.sp_cdc_enable_db;
EXEC sys.sp_cdc_enable_table
@source_schema = N'dbo', @source_name = N'Orders',
@role_name = NULL, @supports_net_changes = 1;@supports_net_changes = 1 requires a primary key or unique index and lets the agent read one net row per key per interval.
- The SQL Server Agent service running on the instance (CDC capture and cleanup are SQL Agent jobs) and
SELECTon thecdcschema for the agent login:
GRANT SELECT ON SCHEMA::cdc TO svc_dlh_agent;
GRANT SELECT ON SCHEMA::dbo TO svc_dlh_agent;Configuration
agent_version: 1.9.4
state_db_path: C:/dlh/state/dlh_agent_state.duckdb
connection_information:
connection_type: sql_server
server_name_or_ip: sql01.corp.local
server_port: 1433
database_names: [OrdersDb]
data_retrieval:
historical_load: false
output_format: iceberg
direct_cloud_write: true
storage_base_path: /data/delta_iceberg_tables
output_path: C:/dlh/output
table_name_structure: nested
table_name_casing: lowercase
parquet_compression: zstd
iceberg_expire_snapshots: true
iceberg_version_hint: true
iceberg_metadata_previous_versions_max: 100
iceberg_delete_after_commit: true
iceberg_apply_deletes: true
iceberg_upload_catalog: true
iceberg_path_style: absolute
on_schema_drift_action: migrate_target
on_schema_drift_action_handling: fail_once
databases:
- name: OrdersDb
sync_mode: cdc
schema_refresh_mode: table_changed
tables:
- name: dbo.Orders
all_columns: true
partition_columns: [OrderYear]
- name: dbo.OrderLines
all_columns: true
- name: dbo.Customers
all_columns: true
ct_cdc_delete_mode: logical
masks:
- column: Email
algorithm: partial
param: 3
- name: ref.*
all_columns: true
sync_mode: full
write_strategy: replace
dlh_ref:
org_guid: '<YOUR_ORG_GUID>'
prj_guid: '<YOUR_PROJECT_GUID>'
connection_guid: '<YOUR_CONNECTION_GUID>'
target_schema_prefix: Site003
api_key: '<YOUR_DLH_API_KEY>'
dlh_notifications:
enabled: true
platform_run_report: true
webhooks:
- url: 'https://hooks.slack.com/services/T000/B000/XXXXXXXX'
format: slack| Block | Why it is set this way |
|---|---|
output_format: iceberg with direct_cloud_write: true | Iceberg metadata embeds file locations. Writing directly to S3 makes them s3://... URIs that every engine can resolve; local writes would embed file:// paths. |
table_name_structure: nested, table_name_casing: lowercase | Tables land in namespace default.ordersdb.dbo with plain table names, so an engine that maps Iceberg namespaces to schemas gets ordersdb.dbo.orders. Lowercase avoids case-sensitivity surprises in Athena and Trino. See Output formats. |
sync_mode: cdc on the database | All tables use CDC unless overridden; ref.* overrides to a full replace each run because small reference tables are cheaper to reload than to track. |
partition_columns: [OrderYear] | Partitions the largest table by a column that queries filter on. Only set this on tables where the column exists and has low cardinality. |
iceberg_apply_deletes: true | CDC delete operations remove the rows from the Iceberg table (copy-on-write). dbo.Customers opts out per table with ct_cdc_delete_mode: logical, keeping deleted customers with _cdc_deleted = true. |
iceberg_expire_snapshots: true, iceberg_delete_after_commit: true | Keep the S3 footprint to the current snapshot and a bounded metadata history. Set iceberg_expire_snapshots: false if you want Iceberg time travel. |
iceberg_upload_catalog: true | The agent's catalog file is copied to the warehouse root after each run as a backup for host recovery. Engines do not read it. |
schema_refresh_mode: table_changed | Column metadata is re-read only for tables SQL Server reports as modified. |
What lands in S3
With the platform-issued bucket and the settings above:
s3://<bucket>/<org>/<prj>/Site003/data/delta_iceberg_tables/iceberg/
dlh_iceberg.db catalog backup
default.ordersdb.dbo.db/orders/
metadata/ vN.metadata.json, manifests, version-hint.text
data/OrderYear=2026/*.zstd.parquet
default.ordersdb.dbo.db/orderlines/
default.ordersdb.dbo.db/customers/
default.ordersdb.ref.db/country/Folder names under the warehouse follow the Iceberg catalog convention of <namespace>.db/<table>/; the Creating new Iceberg table: and Iceberg upsert complete: log lines print the identifier of each table, and the metadata JSON records the exact location.
First run and steady state
--config-check, then --diagnose, then one interactive run. The first run creates every table (Creating new Iceberg table: default.ordersdb.dbo.orders then Iceberg table written: ... (N rows, mode=overwrite, snapshots=1)) and records the CDC LSN per table.
Subsequent runs read the CDC change table since the recorded LSN and print Upserting N cdc changes into Iceberg table ... followed by Iceberg upsert complete: ... (total rows: N, snapshots: N). Tables with no changes are skipped without a new snapshot. After every write the agent updates metadata/version-hint.text so DuckDB and Trino find the latest metadata without a catalog.
CDC cleanup retention
CDC's cleanup job removes change rows older than its retention (default 3 days, sys.sp_cdc_change_job @job_type = 'cleanup', @retention = <minutes>). As with Change Tracking, if the agent does not run for longer than the retention the affected tables are reloaded in full on the next run. Keep the retention above your longest plausible outage.
Reading the tables
Snowflake: create an S3 external volume and an object-store catalog integration for Iceberg, then one table per folder pointing at the current metadata file. Refresh with ALTER ICEBERG TABLE ... REFRESH '<metadata file>' on a schedule; Snowflake does not read version-hint.text.
CREATE OR REPLACE CATALOG INTEGRATION dlh_iceberg_catalog
CATALOG_SOURCE = OBJECT_STORE
TABLE_FORMAT = ICEBERG
ENABLED = TRUE;
CREATE OR REPLACE ICEBERG TABLE RAW.SITE003.ORDERS
EXTERNAL_VOLUME = 'dlh_site003_s3_vol'
CATALOG = 'dlh_iceberg_catalog'
METADATA_FILE_PATH = 'default.ordersdb.dbo.db/orders/metadata/v12.metadata.json';Athena and Glue: register each table with the Glue catalog from its metadata location (register_table via Spark or the Glue console) and query it as an Iceberg table. DuckDB can attach the warehouse directly with the Iceberg extension and reads version-hint.text automatically.
Variations
- Static S3 credentials: add a
dlh_storageblock withstorage_type: s3,access_key,secret_key,bucket_nameandregion. It is encrypted in place on the first run. - Azure instead of S3: nothing in
data_retrievalchanges. Iceberg metadata on Azure always usesazure://account.blob.core.windows.net/container/...paths so a Snowflake external volumeSTORAGE_BASE_URLmatches. - Change Tracking instead of CDC: set
sync_mode: ct; everything on the Iceberg side is identical. - Row history: give a table
write_strategy: scd2; see SCD Type 2 history tables. Iceberg and Delta support the same SCD2 semantics.