DLH.io Documentation logoDLH.io Documentation
AgentsSQL Server AgentScenarios

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

  1. 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.
  2. 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.

  1. The SQL Server Agent service running on the instance (CDC capture and cleanup are SQL Agent jobs) and SELECT on the cdc schema 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
BlockWhy it is set this way
output_format: iceberg with direct_cloud_write: trueIceberg 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: lowercaseTables 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 databaseAll 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: trueCDC 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: trueKeep 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: trueThe 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_changedColumn 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_storage block with storage_type: s3, access_key, secret_key, bucket_name and region. It is encrypted in place on the first run.
  • Azure instead of S3: nothing in data_retrieval changes. Iceberg metadata on Azure always uses azure://account.blob.core.windows.net/container/... paths so a Snowflake external volume STORAGE_BASE_URL matches.
  • 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.