AWS Builder Center
From Transactional Databases to Iceberg: Building a Multi-Source CDC Lakehouse on AWS

From Transactional Databases to Iceberg: Building a Multi-Source CDC Lakehouse on AWS

Land CDC data from Oracle, Aurora PostgreSQL, CockroachDB, and Kafka into Apache Iceberg in near real time. We compare two ingestion paths, Amazon Data Firehose and Debezium with Flink, write to both the Glue Data Catalog and S3 Tables, and query the same tables from Athena and Snowflake. An open-source Terraform repo deploys the whole stack.

Disclaimer: We are Amazon Web Services (AWS) employees. The views and opinions expressed in this post are our own and do not necessarily reflect the official policy or position of AWS or Amazon.
Everything in this post is deployable. The full architecture, both ingestion paths, all four sources, and both Iceberg catalogs, lives in an open-source Terraform repository:  sample-multi-source-cdc-iceberg-lakehouse . Clone it, point it at your account, and you can follow along with real infrastructure.
Note: the Oracle JDBC driver is not bundled. The repository is MIT-0 licensed and can't redistribute Oracle's  ojdbc11  driver, which Oracle licenses under its Free Use Terms and Conditions. If you want the Oracle CDC path, bring the driver yourself. Before deploying the datasources, edit  the generator's pom.xml  and change the  ojdbc11  scope from  provided  to  compile . The build pulls the driver from Maven Central and bundles it. Downloading it this way means you accept Oracle's terms. Aurora PostgreSQL, CockroachDB, and MSK work without this step.
Whether you're building a data layer to feed features to an ML team, giving an agentic application a better grip on your company's data, or getting your BI analysts a fresher view of the business, the same question keeps coming up: how do I take the data scattered across all my operational systems and make it usable, together, in near real time?
That data rarely lives in one place. It's in relational databases like Oracle and Aurora PostgreSQL, in distributed SQL stores like CockroachDB, in Kafka topics on-prem or in Confluent Cloud, and in a dozen other systems. The classic answer was a nightly batch job that dumps everything into Amazon S3 with AWS Glue as the Hive metastore. That's fine when "daily" is fresh enough. It stops being fine the moment someone wants to decide on data that's an hour old instead of a day old. And the old Hive-era headaches never really went away: rigid directory layouts, no time travel, queries that crawl as the data piles up.
The common escape hatch is to ship everything into a warehouse like Snowflake. Great query performance, and it takes the ingestion problem off your plate, but you pay for that storage twice and give up control over where and how your data lives.
This post takes a third route: land your operational data in  Apache Iceberg , an open table format built for large analytic datasets, while keeping control of both the streaming pipeline and the storage. We'll build two ingestion paths against the same four sources, land the data in Iceberg on both AWS Glue Data Catalog and Amazon S3 Tables, and query it from Athena and Snowflake. Every claim in this post comes from running that stack.

Two paths to the same lakehouse

In this post we'll walk through two options for ingesting operational data into Iceberg. These are the two approaches we see customers adopt most often, and we'll call them Path 1 and Path 2 throughout. They differ in who runs the moving parts. Path 1 is almost entirely AWS-managed; Path 2 trades some operational work for control.
At the center of both paths sits Amazon Managed Streaming for Apache Kafka (Amazon MSK). It's the universal ingestion hub. It decouples capture from delivery, so how you read Oracle is independent of how you land Iceberg files, and it concentrates the hardest operational skill, running a stream, into one service both paths reuse. Every source publishes change events to Kafka topics on the same cluster, and every Iceberg writer reads from those topics. On top of MSK, the paths diverge in how they get data from Kafka into Iceberg.
Path 1 is Amazon Data Firehose with an AWS Lambda transformer. Firehose is AWS-managed, connects to MSK over AWS PrivateLink, supports Iceberg as a first-class destination, and lets a Lambda function reshape each record before it lands. It fits when you want the least to operate, your transformations are stateless, and Firehose's Iceberg write semantics work for you.
Path 2 is Debezium on Amazon MSK Connect for capture, paired with Apache Flink on Amazon Managed Service for Apache Flink for the sink. Debezium emits CDC events as JSON to MSK; a Flink job per source consumes them, parses the source-specific envelope in Java, and writes typed records to Iceberg with exactly-once semantics tied to Flink's checkpoints. It fits when CDC fidelity matters, when a source envelope needs custom handling, and when your team is comfortable with a small Java codebase.
The two sit at different points on the managed-versus-control spectrum, and the choice comes down to which end your team values more.
Both paths land data in two Iceberg targets in parallel. The primary is Iceberg on S3 registered in the AWS Glue Data Catalog, which gives the broadest engine support and full control over compaction and partition layout. The secondary is  Amazon S3 Tables , managed Iceberg storage with automatic compaction, snapshot expiration, and unreferenced-file cleanup, served through its own Iceberg REST catalog. Both share the same schema per source, so an Athena query returns identical results regardless of which catalog it hits.
One setup note: the MSK cluster runs dual authentication. Path 1 (DMS, Firehose, the data generators) uses SASL/SCRAM; Path 2 (Debezium and Flink) uses IAM, the cleaner option for VPC-attached AWS compute. Both are enabled on the same cluster so the paths can share brokers and topics.

Change data capture: where the paths diverge

Downstream of MSK the two paths look nearly identical. At the source they diverge, because every database exposes its transaction log differently and no single tool speaks every dialect.
AWS DMS is the AWS-managed capture option. It reads Oracle through its Binary Reader (reading the redo logs directly) and Aurora PostgreSQL through logical replication slots ( pgoutput ), writing flat JSON to MSK over SASL/SCRAM. Its strength is operational simplicity. You get a managed replication instance with its own scaling and monitoring, and no connector plugins to manage. Its trade-off is fidelity. DMS flattens CDC semantics: before-images are dropped by default, multi-statement transactions aren't grouped, and a few source quirks leak through and get normalized downstream by Path 1's Lambda.
Debezium is the Kafka-native option. It reads the same source mechanisms but emits richer events: a full envelope with the operation (insert, update, delete), source metadata, and the post-change row, as JSON on per-table topics. Its  ExtractNewRecordState  transform flattens that envelope and promotes the operation to a top-level  __op  field so consumers can branch on it. Debezium runs as a source connector on MSK Connect with IAM auth. You get the operation code on every record and fine-grained control over what reaches MSK, at the cost of managing connector plugins, MSK Connect quotas, per-connector IAM roles, and source-specific knobs like the Oracle mining strategy and PostgreSQL replication-slot behavior.
A rough rule: DMS is the right default when downstream workloads are row-state analytics and your team doesn't already run Kafka Connect. Debezium is the right default when the operation code matters per record, per-table routing simplifies consumers, or Kafka Connect is already part of your platform. Because MSK sits in the middle, you can start with one and migrate pieces to the other later.
CockroachDB is the exception, and it needs explaining before we look at either path in detail.

Why CockroachDB needs a different path

CockroachDB is a distributed SQL database. Data splits into ranges, each range replicates across nodes via Raft, and each node keeps its own local storage engine (Pebble). There's no single write-ahead log to tail; there are thousands of independent Raft logs across the cluster.
At the application layer it looks exactly like PostgreSQL: same JDBC driver, same wire protocol, same SQL. The natural assumption is that PostgreSQL CDC should work, but it doesn't. CDC operates at the replication layer, and CockroachDB's replication layer is Raft, not PostgreSQL logical decoding. There are no replication slots for DMS or Debezium to call. The wire-protocol compatibility goes no deeper than SQL.
Because external tools can't read its internal stream, CockroachDB builds CDC into the engine as changefeeds. Setup is two SQL statements ( setup-cockroachdb-changefeeds.sh  runs them for you):
SQL
SET CLUSTER SETTING kv.rangefeed.enabled = true;

CREATE CHANGEFEED FOR TABLE defaultdb.financial_transactions
INTO 'external://msk_financial'
WITH format = json, updated, resolved = '30s', envelope = 'enriched', key_in_value;
One node acts as changefeed coordinator, but it's purely an administrator and processes no data. Each range has one replica watching for changes; when a row changes, that node encodes it and sends it straight to Kafka. The coordinator maintains a high-water mark and periodically writes a  resolved  message to each partition, a consistency marker telling consumers everything before that timestamp has been emitted. The database itself is the CDC producer.
We use the enriched envelope for its explicit  op  field, plus  key_in_value  to embed the primary key, which is essential for deletes where the  after  field is null:
JSON
// insert / update / delete
{ "after": {"transaction_id": "TXN-001", "amount": 150.00}, "key": {"transaction_id": "TXN-001"}, "op": "c" }
{ "after": {"transaction_id": "TXN-001", "amount": 200.00}, "key": {"transaction_id": "TXN-001"}, "op": "u" }
{ "after": null, "key": {"transaction_id": "TXN-001"}, "op": "d" }
Both paths handle this envelope. On Path 1, the Lambda transformer reads  op , maps  c / u  to insert/upsert and  d  to delete, and drops resolved markers by their absent  after . On Path 2, the Flink job filters resolved markers and tags each record as an insert, update, or delete (we'll see the mechanics in the Flink section). The CDC mechanism is completely different from the other sources; the lakehouse view is identical. CockroachDB rows land in the same tables as the Oracle and Aurora rows, indistinguishable once at rest.

Path 1: Amazon Data Firehose

In Path 1 the CDC engine is AWS DMS with two tasks, reading from Oracle on Amazon EC2 and from Aurora PostgreSQL. DMS writes flat JSON to MSK; Firehose consumes those topics over PrivateLink; a Lambda transformer flattens and normalizes each record; and Firehose writes to Iceberg with configurable buffering. It's a short pipeline with almost nothing to operate, which is the point. The two pieces worth a closer look are the delivery stream itself and the Lambda in the middle.

Firehose as the Iceberg writer

Each Kafka topic gets its own Firehose delivery stream, declared entirely in Terraform ( msk-firehose-ingestion module ). The stream names the MSK cluster as its source, Iceberg as its destination, and the Glue table it writes to. The part that makes CDC work is  unique_keys . It tells Firehose which column identifies a row, so an update replaces the existing row instead of appending a duplicate:
Terraform
resource "aws_kinesis_firehose_delivery_stream" "delivery_stream" {
destination = "iceberg"

msk_source_configuration {
msk_cluster_arn = var.MSK_CLUSTER_ARN
topic_name = var.TOPIC_NAME
authentication_configuration {
connectivity = "PRIVATE" # PrivateLink into the VPC
role_arn = aws_iam_role.firehose_role.arn
}
}

iceberg_configuration {
catalog_arn = "arn:aws:glue:...:catalog"
buffering_size = var.BUFFERING_SIZE
buffering_interval = var.BUFFERING_INTERVAL

destination_table_configuration {
database_name = var.GLUE_DATABASE_NAME
table_name = var.GLUE_TABLE_NAME
unique_keys = ["transaction_id"] # upsert key
}
}
}
There is no consumer application to run. Firehose polls the topic, batches records by the buffering settings, and commits them to the Iceberg table. Records that fail transformation land under an error prefix in S3 rather than blocking the stream.

The Lambda transformer

Firehose applies inserts, updates, and deletes to Iceberg based on an operation field the transformer attaches to each record. That contract is the whole job of  transformer_function.py . One function handles all four sources, because by this point everything is JSON on MSK and only the envelope differs. For each record it:
  • detects the envelope (DMS's  data  plus  metadata , CockroachDB's  after  plus  key , or a plain Kafka message),
  • extracts the row and lowercases Oracle's UPPERCASE column names,
  • maps the source's operation code to  insert ,  update , or  delete  and returns it as  otfMetadata , which tells Firehose how to apply the row,
  • and drops CockroachDB's  resolved  timestamp markers, which are consistency signals rather than data.
The delete case shows why the envelope detection matters. A CockroachDB delete arrives with  after  set to null, so the row identity has to come from the  key  field instead:
Python
operation_mapping = {"u": "update", "d": "delete"}
op_code = record.get("op")
operation = operation_mapping.get(op_code, "insert") if op_code else "insert"

if operation == "delete":
transaction_data = record.get("key") # 'after' is null on deletes
else:
transaction_data = record.get("after")
That's everything Path 1 asks you to operate: a managed delivery stream per topic and one stateless Lambda. Nothing to patch, no offsets to manage, no cluster to size.

Path 2: Debezium and Apache Flink

Path 2 is the Kafka-native, open-source path. Debezium on MSK Connect captures CDC from Oracle and Aurora, CockroachDB publishes its own native changefeeds, the MSK source cluster carries direct Kafka events, and a fleet of Flink jobs reads each topic, parses the source-specific envelope in Java, and writes typed records into Iceberg on both catalogs. Once a row reaches MSK, the rest of the pipeline doesn't care which database it came from. Every source converges on the same record-shape contract, and only the upfront envelope translation is source-specific.

Capturing CDC with Debezium

Oracle's CDC story revolves around the redo logs (Oracle's write-ahead log) and LogMiner, a built-in utility that reads the redo log and exposes changes as queryable rows. The  Debezium Oracle connector  polls LogMiner, picks up changes since its last position, and publishes each as a Kafka message. For LogMiner to decode what changed rather than just that something did, the DBA enables archive log mode and supplemental logging. PostgreSQL is analogous, built on the write-ahead log and logical decoding through a replication slot and the  pgoutput  plugin; on Aurora you enable it with  rds.logical_replication=1  and grant the connector user the  rds_replication  role. The  Debezium PostgreSQL connector  has the full reference.
The connectors are nearly identical to configure ( debezium-oracle/main.tf  and  debezium-aurora/main.tf  in the repo). Here's the Oracle one; the PostgreSQL connector swaps the connector class, adds  plugin.name=pgoutput  and a  slot.name , and drops the  database.pdb.name :
properties
connector.class = io.debezium.connector.oracle.OracleConnector
database.hostname = oracle-host.example.com
database.dbname = ORCLCDB
database.pdb.name = ORCLPDB1
topic.prefix = oracle-cdc
table.include.list = <tables to include>
log.mining.strategy = online_catalog
snapshot.mode = initial
decimal.handling.mode = precise
value.converter = org.apache.kafka.connect.json.JsonConverter
value.converter.schemas.enable = false
transforms = unwrap
transforms.unwrap.type = io.debezium.transforms.ExtractNewRecordState
transforms.unwrap.add.fields = op,source.ts_ms
transforms.unwrap.delete.tombstone.handling.mode = rewrite
That  ExtractNewRecordState  transform is the one to know. By default Debezium produces a nested envelope consumers have to unpack; the transform replaces the body with just the post-change row and adds a top-level  __op  field ( c  for insert,  u  for update,  d  for delete). By the time data reaches Flink, every source looks the same: JSON on MSK topics, IAM-authenticated, ready to consume.

Flink as the sink

Flink on Amazon Managed Service for Apache Flink takes the runtime off your plate. There are no clusters to provision and no upgrades to schedule. It writes to Iceberg through the official  iceberg-flink-runtime  connector, whose  FlinkSink.forRowData(...)  builder ties Iceberg's commit lifecycle to Flink's checkpoint barriers. When a checkpoint barrier flows through, the writer flushes its in-progress data files and the Iceberg committer atomically updates the catalog. If the job fails between checkpoints, Flink restores from the last successful one and abandons the in-flight files, giving exactly-once Iceberg writes.
The reason to use Flink over a config-driven Kafka Connect sink is programmability. Each job is a few hundred lines of Java that does whatever a record needs: case-insensitive field lookup, base64 decimal decoding, resolved-marker filtering,  RowKind  tagging. If you later need windowing, stream joins, or per-key state, the runtime already supports it. A job wires together a Kafka source, an envelope mapper, and two Iceberg sinks (full version in  OracleCdcJob.java , with  AuroraCdcJob ,  CockroachCdcJob , and  MskAppendJob  as the per-source variants):
Java
public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.enableCheckpointing(60_000); // commit cadence, tunable; see the latency section

CatalogLoader glueLoader = CatalogLoader.custom("glue_catalog", Map.of(
CatalogProperties.CATALOG_IMPL, "org.apache.iceberg.aws.glue.GlueCatalog",
CatalogProperties.WAREHOUSE_LOCATION, "s3://your-warehouse/",
CatalogProperties.FILE_IO_IMPL, "org.apache.iceberg.aws.s3.S3FileIO"),
new Configuration(), "org.apache.iceberg.aws.glue.GlueCatalog");

// S3 Tables loader is the same builder against the Iceberg REST catalog with SigV4 signing
buildPipeline(env, glueLoader, s3TablesLoader, "your-kafka-topic", "my_db.my_table", "primary_key");
env.execute("MyCdcJob");
}
 buildPipeline  creates a Kafka source with IAM auth, loads the Iceberg schema once at startup, attaches a source-specific mapper, and wires the  RowData  stream into both sinks in upsert mode keyed on the primary key:
Java
DataStream<RowData> stream = env
.fromSource(source, WatermarkStrategy.noWatermarks(), "kafka-" + kafkaTopic)
.map(new MyEnvelopeMapper(schema))
.name("parse-" + kafkaTopic);

FlinkSink.forRowData(stream)
.tableLoader(TableLoader.fromCatalog(glueLoader, tableId))
.upsert(true).equalityFieldColumns(List.of(primaryKey)).append();
// a second FlinkSink writes the same stream to the S3 Tables catalog
The mapper is the variable piece. It reads each message, determines the operation, and produces a typed  RowData  aligned to the Iceberg schema:
Java
public RowData map(String message) throws Exception {
JsonNode node = JSON.readTree(message);
String op = node.has("__op") ? node.get("__op").asText() : "c";
RowKind rowKind = switch (op) {
case "u" -> RowKind.UPDATE_AFTER;
case "d" -> RowKind.DELETE;
default -> RowKind.INSERT;
};
GenericRowData row = new GenericRowData(schema.columns().size());
for (int i = 0; i < schema.columns().size(); i++) {
Types.NestedField col = schema.columns().get(i);
row.setField(i, convertJsonToIcebergType(node.get(col.name()), col.type()));
}
row.setRowKind(rowKind);
return row;
}
Each source needs a small variation. Oracle emits UPPERCASE column names, so the mapper does case-insensitive lookups, and its numeric columns arrive base64-encoded (Debezium's precise decimal mode), decoded with the column's declared scale. PostgreSQL is the same but lowercase. CockroachDB's enriched envelope needs resolved-marker filtering. Plain Kafka topics need almost nothing, since every record is already a flat insert.

The Iceberg sinks

Both catalogs are production-ready. The Glue Data Catalog is a managed metastore for Iceberg tables in an S3 bucket you own, where you handle compaction and snapshot maintenance yourself. Amazon S3 Tables is purpose-built storage that runs that maintenance for you, exposed through an Iceberg REST catalog endpoint the writer reaches with SigV4 signing. Either way the sink runs in upsert mode: for an insert or update it writes an equality-delete file marking any existing row with the same key, then the new row; for a delete it writes only the delete file. New rows become visible at the next successful Iceberg commit.
That last point, "the next successful commit," turns out to be the whole story on latency.

How the two paths compare

Running both paths against the same data on identical tables surfaces the difference that matters most in practice: how fresh the data is, and how much each path can take.

Latency is about when data becomes queryable

What counts is when a query can see the new row, not when the file lands in S3. That moment is the Iceberg commit, and both paths gate on it.
Firehose has a floor of about a minute (we measured 55 to 70 seconds), and the buffering setting doesn't lower it. Setting the buffer interval to zero drives the S3-write metric ( DeliveryToS3.DataFreshness ) to nearly zero, files hit S3 almost immediately, but the Iceberg metric ( DeliveryToIceberg.DataFreshness ) stays pinned around a minute. The two diverge because Firehose delivers to Iceberg in two stages: it writes Parquet to S3 (which the buffer controls), then a separate commit stage registers those files into the table on its own cadence. From the outside that commit cadence looks like a fixed ~60-second floor, and no delivery-stream setting moves it.
Flink commits on its checkpoint interval, 60 seconds in our configuration, landing it in the same ballpark. The difference is that the checkpoint interval is a dial you control: shorten it and time-to-queryable drops, at the cost of more, smaller files to compact later. So by default both paths make data queryable in tens of seconds, and the real distinction is tunability. Firehose's latency floor is fixed; Flink's is a knob. Same managed-versus-control trade-off, expressed in seconds.

Throughput

Both paths absorb typical CDC volumes without losing a row. Firehose keeps up with sustained bursts; its consumer lag climbs under heavy load but drains back to zero.
For Flink, the number depends entirely on how you size the job, so it's worth being specific about what we ran. Each Flink app in this repository is configured at parallelism 4 with one vCPU per KPU, which works out to roughly 4 vCPU and 16 GB. At that sizing, a single job held a steady ~4,000 records per second and then flattened, with CPU pinned at 100%, busy time at 100%, and backpressure at zero. Fully busy and CPU-bound with nothing pushing back downstream means the job's own compute was the limit at this configuration, not the Iceberg sink or S3.
Because the limit here is compute and parallelism controls compute, raising parallelism scales throughput close to linearly, so this figure moves with the sizing you choose rather than being a fixed property of Flink. At the parallelism-4 configuration in the repo, roughly 3,000 records per second per source ran comfortably at around 75% CPU. (A single-partition topic also caps how many threads can read it, so at higher parallelism you would add partitions too.)

Querying the lakehouse

You adopt an open table format so any engine can read the same data without copying it. With data landed in Iceberg on Glue and S3 Tables, connecting engines is straightforward (S3 Tables documents its  open-source engine integrations  directly), with one cross-cutting caveat: Lake Formation credential vending only works with AWS-native engines, so Snowflake, Databricks, and Trino bypass it.

Amazon Athena

Athena has the simplest Iceberg experience. No JARs, no Spark session. If a table is registered in Glue with  table_type = 'Iceberg' , Athena  queries it immediately . It supports full DML ( INSERT ,  UPDATE ,  DELETE ,  MERGE INTO  on engine v3), all merge-on-read, writing position-delete files rather than rewriting data:
SQL
MERGE INTO analytics_db.customer_orders t
USING staging_db.cdc_batch s ON t.order_id = s.order_id
WHEN MATCHED AND s.op = 'D' THEN DELETE
WHEN MATCHED THEN UPDATE SET t.order_total = s.order_total
WHEN NOT MATCHED THEN INSERT (order_id, order_total) VALUES (s.order_id, s.order_total);
Athena also does time travel by snapshot ID or timestamp, and  table maintenance  is built in ( OPTIMIZE  for bin-pack compaction,  VACUUM  for snapshot expiry). For S3 Tables, use the  federated child-catalog path , not just  s3tablescatalog  as the parent. A few limits are worth knowing: S3 Tables is Iceberg V2 only, table and column names must be lowercase, compaction is bin-pack only (use EMR for sort or z-order), and Athena can't  CREATE / ALTER / DROP   Lake Formation-governed  Iceberg tables via DDL.

Snowflake

Snowflake is where the interesting edge cases live. It offers two table types:  Snowflake-managed Iceberg tables  (Snowflake owns the lifecycle and writes standard Iceberg metadata to S3, readable by any engine, with full DML from Snowflake) and externally-managed tables (an external catalog like Glue or the S3 Tables REST endpoint owns the table, and Snowflake reads and, with caveats, writes through it). Historically externally-managed tables were read-only; Snowflake now supports externally-managed writes for Iceberg v2+ tables, with the constraints listed at the end of this section ( Manage Iceberg Tables ).
Three objects do the work: an  External Volume  for S3 access, a  Catalog Integration  for metadata, and an  Iceberg Table  binding. The official docs cover the syntax and IAM policies in detail; what follows is what those docs don't make obvious. For multi-table setups,  catalog-linked databases  are worth knowing about: Snowflake auto-discovers and mirrors every namespace and table from a REST catalog into a Snowflake database, which beats a  CREATE ICEBERG TABLE  per table when the upstream catalog is dynamic.
The Glue Data Catalog path alternates between AWS and Snowflake:
  1. AWS: create an S3 bucket, IAM policy, and IAM role with a placeholder trust policy. The  Snowflake S3 storage integration guide  has the exact IAM policy JSON, and the Snowsight wizard generates the same policy.
  1. Snowflake: create the external volume, then capture  STORAGE_AWS_IAM_USER_ARN  and  STORAGE_AWS_EXTERNAL_ID  from  DESC EXTERNAL VOLUME .
  1. AWS: replace the placeholder trust policy with those values, and verify with  SELECT SYSTEM$VERIFY_EXTERNAL_VOLUME('iceberg_vol');  before moving on.
  1. Snowflake: create the  Glue catalog integration , and capture the new  GLUE_AWS_IAM_USER_ARN  and  GLUE_AWS_EXTERNAL_ID , which differ from the external volume's pair.
  1. AWS: add the catalog integration's principal as a second statement in the trust policy, so the role trusts both Snowflake identities.
  1. Snowflake:  CREATE ICEBERG TABLE  referencing the volume and integration, then query.
What those docs don't tell you:
  • The ordering wrinkle. The IAM role has to exist before you create the Snowflake objects (Snowflake needs the ARN), but the trust policy can't be finalized until after Snowflake generates its principal and external ID. So you bounce between the two consoles: AWS placeholder first, then Snowflake, then back to AWS with the real values, then Snowflake again. The Snowsight wizard surfaces this naturally; if you're scripting it, plan for the round trip.
  • One role, multiple external IDs. Every Snowflake object that uses an IAM role gets its own external ID. Sharing one role between an external volume and a catalog integration (very common) means the trust policy needs both pairs, either as two  Allow  statements or one statement with an  sts:ExternalId  array.
  •  SYSTEM$VERIFY_CATALOG_INTEGRATION  doesn't work for Glue. It returns  error 004186: validation operation is not supported for this catalog type: GLUE . That's not a real failure; Snowflake just hasn't built a Glue validator. Go straight to  CREATE ICEBERG TABLE , and if it succeeds the integration is fine.
  •  CATALOG_TABLE_NAME  is case-sensitive and must match the Glue table name exactly.
Once registered,  AUTO_REFRESH  keeps Snowflake in sync by polling the Glue catalog on the  REFRESH_INTERVAL_SECONDS  cadence (default 30s). When your CDC pipeline commits a new snapshot, the next poll picks it up. Iceberg's single  metadata.json  pointer means no SNS/SQS plumbing is needed.
The S3 Tables path is harder, because the obvious approaches fail and the working one isn't in Snowflake's own docs. (The repo's  snowflake-integration guide  walks through the working setup.) Three approaches exist; only the third works:
  • Direct S3 Tables REST endpoint ( https://s3tables.<region>.amazonaws.com/iceberg ) with SigV4 fails with  Forbidden: Credential should be scoped to correct service: 's3tables' . Snowflake derives the signing service from  CATALOG_API_TYPE , and none of the allowed values sign as  s3tables .
  •  CATALOG_SOURCE = GLUE  against the federated Glue catalog ( <account>:s3tablescatalog/<bucket> ) fails because Snowflake's  GLUE_CATALOG_ID  accepts only a 12-digit account ID and rejects the federated format ( Glue catalog integration docs ).
  • Glue Iceberg REST endpoint ( https://glue.<region>.amazonaws.com/iceberg ) with  CATALOG_API_TYPE = AWS_GLUE  and the federated catalog ID as the warehouse is the working path. Snowflake signs as service  glue , the Glue endpoint accepts it, and Glue routes the request to S3 Tables internally. The AWS blog  Connect Snowflake to S3 Tables using the SageMaker Lakehouse Iceberg REST endpoint  is the canonical reference; follow it end to end.
That blog gets you most of the way. A few things to watch for on top of it:
  •  lakeformation:GetDataAccess  is a non-obvious required IAM permission on the Snowflake role; the Glue endpoint calls it to vend credentials.
  • Lake Formation grants must be  ALL  (SUPER in the console), not just  SELECT , for external-engine vended credentials ( LF permissions reference ).
  • No  EXTERNAL_VOLUME  on the table DDL. The S3 Tables path uses  ACCESS_DELEGATION_MODE = VENDED_CREDENTIALS , where Lake Formation vends scoped credentials per request. Including  EXTERNAL_VOLUME  returns "Cannot create an Iceberg table with an external volume and a catalog integration that supports credential vending."
  •  glue:UpdateTable  is the silent writer-killer. Every commit ends with a direct Glue call to update the  metadata_location  pointer. Without it, the Parquet write succeeds but the commit fails, leaving orphan Parquet files on S3 with no manifest pointing at them.
  • You'll need to be a Lake Formation data lake admin. In prototype accounts where infrastructure was deployed by a scoped role, that role is often the only admin. Add yourself before trying to grant LF permissions to the Snowflake role.
  • Lake Formation hides Glue tables from non-admins. If  aws glue get-tables  returns  []  but  aws glue create-table  returns  AlreadyExistsException , Lake Formation is filtering the response. Becoming a data lake admin resolves it.
On governance, the two paths differ. On the Glue path Snowflake reads S3 directly with its own IAM role and never calls  GetTemporaryGlueTableCredentials , so column/row/cell-level LF policies aren't honored, and even table-level LF permissions are bypassed. On the S3 Tables + Glue Iceberg REST path, Lake Formation is in the picture for table-level vended credentials, but column/row/cell filters still aren't honored, since those only work for native AWS engines like Athena, EMR, and Redshift Spectrum. For fine-grained control inside Snowflake, use its own  masking and row-access policies .
Externally-managed writes come with a specific set of constraints:
  • Position deletes only, no equality deletes.
  • Cannot write  uuid  or  fixed(L) .
  • Autocommit only, no multi-statement transactions.
  • Snowflake won't run snapshot expiry, orphan cleanup, or compaction, so schedule those from the writing engine ( Iceberg maintenance ) or rely on S3 Tables auto-maintenance.
  •  DROP TABLE  fails on S3 Tables (no PURGE flag); use  aws s3tables delete-table .
  • Catalog-linked databases don't allow nested-namespace writes.
A few smaller gotchas from our prototype builds are worth flagging too: dots in bucket names break SSL ( S3 bucket naming rules ); nanosecond timestamps get truncated to microseconds; merge-on-read delete-file performance degrades without scheduled compaction; and failed writes leave orphan Parquet files, so run   remove_orphan_files   periodically.

A note on maintenance

Every buffer flush and every checkpoint commit creates new data files, manifests, and snapshots. Without maintenance you accumulate thousands of small files, the metadata tree grows, and queries slow as engines spend more time on S3  LIST  calls than on scanning. This is the small-file problem, and every streaming-to-Iceberg architecture has to solve it. It's the clearest place the two storage targets differ: on Glue-cataloged Iceberg you own maintenance (run bin-pack compaction with EMR Spark's  rewrite_data_files , on a schedule or file-count threshold), while  S3 Tables does it automatically  (compaction toward 256 MB files, snapshot expiry after 24 hours, unreferenced-file cleanup after 3 days). If operational simplicity is the goal, that's the strongest argument for S3 Tables; if you need cross-region replication or the broadest engine support, Glue-cataloged Iceberg is the more mature choice.

Wrapping up

The goal was to take operational data spread across Oracle, Aurora, CockroachDB, and Kafka and land it in Apache Iceberg in near real time, without giving up control of the pipeline or the storage. Two paths get you there: an AWS-managed one on Firehose, and a Kafka-native one on Debezium and Flink, both feeding the same tables and queryable from the same engines.
Which one you reach for depends on your team. If you want to write no code and operate almost nothing, Firehose is the answer, and you accept a fixed latency floor in return. If you want to shape every record in Java, dial latency down, and keep the door open to stateful processing later, Flink earns the codebase it costs you. Either way you get clean, queryable, exactly-once data in Iceberg that Athena, Snowflake, and any other Iceberg-aware engine can read without a copy.
The whole architecture, both paths and all four sources writing to both catalogs, is in  sample-multi-source-cdc-iceberg-lakehouse  and deploys end to end. Point it at your account, generate some data, and watch it flow.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article