I tried referencing Iceberg tables synced to S3 Tables from Snowflake using DynamoDB Zero-ETL integration

I tried referencing Iceberg tables synced to S3 Tables from Snowflake using DynamoDB Zero-ETL integration

How to implement data sync from DynamoDB to S3 Tables and Snowflake integration, summarizing actual build steps and operation results.
2026.10.21

This page has been translated by machine translation. View original

Introduction

Amazon DynamoDB can replicate DynamoDB tables to Amazon SageMaker Lakehouse as a source through Zero-ETL integration.
At this time, S3 Tables can be specified as the target catalog. This allows DynamoDB data to be written as Apache Iceberg format tables in an S3 table bucket.

S3 table buckets provide an Iceberg REST endpoint, which can be queried directly from compatible clients.

Snowflake, through a feature called catalog link databases, connects to external Iceberg REST catalogs and automatically discovers and synchronizes namespaces and Iceberg tables from external catalogs, making them accessible from Snowflake.

In this article, I tried the steps to integrate DynamoDB tables through Zero-ETL integration and reference tables synchronized to S3 Tables from Snowflake.

DynamoDB Zero-ETL Integration

DynamoDB's Zero-ETL integration is a feature that synchronizes DynamoDB table data to a SageMaker Lakehouse (Glue Data Catalog) target via AWS Glue.

This feature is described below.

https://docs.aws.amazon.com/ja_jp/amazondynamodb/latest/developerguide/amazon-sagemaker-lakehouse-for-DynamoDB.html

https://aws.amazon.com/jp/about-aws/whats-new/2024/12/amazon-dynamo-db-zero-etl-integration-sagemaker-lakehouse/

The main points are as follows.

  • The first run synchronizes the full dataset, and subsequent runs use change data capture (CDC) to synchronize only the added, updated, and deleted differences
  • Internally, it uses exports from DynamoDB's PITR (Point-in-Time Recovery), so it does not consume the table's read capacity
  • The update interval can be set to a minimum of 15 minutes

When near-real-time integration between DynamoDB and an analytics platform is required, using DynamoDB Streams may be necessary, but Zero-ETL integration is characterized by its ability to continue synchronization without building or operating an ETL pipeline.

The following articles are also available as verification of this feature. They should also be helpful as references.

https://dev.classmethod.jp/articles/amazon-dynamodb-zeroetl-lakehouse/

https://dev.classmethod.jp/articles/dynamodb-sagemaker-lakehouse-zero-etl-cross-account-integration/

Prerequisites

Verification Environment

The following environment is used.

  • AWS Region: Tokyo (ap-northeast-1)
  • Source: Amazon DynamoDB
  • Target: S3 Tables catalog (table bucket lab-zetl-analytics, s3tablescatalog/lab-zetl-analytics)
  • Query engines: Amazon Athena, Snowflake

Preliminary Setup

Create a DynamoDB Table

For verification, create a table lab-orders assuming order data. The partition key is order_id.
For Zero-ETL integration, PITR must be enabled on the source table. Also, set a resource policy on the table so that Glue can export the table.

Created with the following CloudFormation template.

DynamoDB table CloudFormation template
AWSTemplateFormatVersion: "2010-09-09"
Description: DynamoDB table for Zero-ETL verification (PITR + resource policy for Glue)

Parameters:
  TableName:
    Type: String
    Default: lab-orders

Resources:
  OrdersTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: !Ref TableName
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: order_id
          AttributeType: S
      KeySchema:
        - AttributeName: order_id
          KeyType: HASH
      PointInTimeRecoverySpecification:
        PointInTimeRecoveryEnabled: true
      ResourcePolicy:
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Sid: AllowGlueZeroEtlExport
              Effect: Allow
              Principal:
                Service: glue.amazonaws.com
              Action:
                - dynamodb:ExportTableToPointInTime
                - dynamodb:DescribeTable
                - dynamodb:DescribeExport
              Resource: "*"
              Condition:
                StringEquals:
                  aws:SourceAccount: !Ref AWS::AccountId
                ArnLike:
                  aws:SourceArn: !Sub arn:aws:glue:${AWS::Region}:${AWS::AccountId}:integration:*

Since the integration does not exist at this point and its ARN is unknown, a wildcard (integration:*) is used for aws:SourceArn in the resource policy. After creating the integration, it will be narrowed down to the integration ARN in the steps described later.

https://docs.aws.amazon.com/glue/latest/dg/zero-etl-sources.html#zero-etl-config-source-dynamodb

Sample Data

As sample data, 1,000 order records with the following attributes were added.

Attribute Type Content
order_id String Order ID (key)
customer_id, category, status, prefecture, ordered_at String Customer, category, status, etc.
quantity, unit_price, total_amount Number Quantity, unit price, total amount

In addition, one item containing nested maps, lists, and sets was added to verify type conversion.

{
  "order_id":     { "S": "ORD-NESTED-0001" },
  "quantity":     { "N": "2" },
  "discount_rate": { "N": "0.15" },
  "is_gift":      { "BOOL": true },
  "gift_note":    { "NULL": true },
  "shipping": {
    "M": {
      "method":  { "S": "宅配便" },
      "fee":     { "N": "500" },
      "address": { "M": { "zip": { "S": "100-0001" }, "city": { "S": "千代田区" } } }
    }
  },
  "items": {
    "L": [
      { "M": { "sku": { "S": "BK-001" }, "qty": { "N": "1" } } },
      { "M": { "sku": { "S": "BK-002" }, "qty": { "N": "1" } } }
    ]
  },
  "mixed_list": { "L": [ { "S": "a" }, { "N": "1" } ] },
  "tags":   { "SS": ["ギフト", "急ぎ"] },
  "scores": { "NS": ["1", "2.5"] }
}

Create an S3 Table Bucket and IAM Role

Create the target S3 table bucket lab-zetl-analytics and an IAM role required for Glue to write to S3 Tables when the integration runs.

CloudFormation template for S3 table bucket and IAM role
Resources:
  AnalyticsTableBucket:
    Type: AWS::S3Tables::TableBucket
    Properties:
      TableBucketName: lab-zetl-analytics

  GlueTargetRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: lab-zetl-glue-role
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: glue.amazonaws.com
            Action: sts:AssumeRole
            Condition:
              StringEquals:
                aws:SourceAccount: !Ref AWS::AccountId
      Policies:
        - PolicyName: s3tables-write
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - s3tables:ListTableBuckets
                  - s3tables:GetTableBucket
                  - s3tables:GetTableBucketEncryption
                  - s3tables:GetNamespace
                  - s3tables:CreateNamespace
                  - s3tables:ListNamespaces
                  - s3tables:CreateTable
                  - s3tables:DeleteTable
                  - s3tables:GetTable
                  - s3tables:GetTableEncryption
                  - s3tables:ListTables
                  - s3tables:GetTableMetadataLocation
                  - s3tables:UpdateTableMetadataLocation
                  - s3tables:GetTableData
                  - s3tables:PutTableData
                Resource:
                  - !GetAtt AnalyticsTableBucket.TableBucketARN
                  - !Sub ${AnalyticsTableBucket.TableBucketARN}/table/*
              - Effect: Allow
                Action:
                  - cloudwatch:PutMetricData
                Resource: "*"
                Condition:
                  StringEquals:
                    cloudwatch:namespace: AWS/Glue/ZeroETL
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: "*"

The permissions for this role are aligned with the target IAM role permissions described in the following documentation (s3tables permissions, CloudWatch metrics, CloudWatch Logs).

https://docs.aws.amazon.com/glue/latest/dg/zero-etl-target.html

Enable Analytics Service Integration for S3 Tables

To make the S3 table bucket visible from the Glue Data Catalog, enable integration with analytics services.
According to the following documentation, this setting is described as a prerequisite when using an S3 table bucket as a target.

https://docs.aws.amazon.com/glue/latest/dg/zero-etl-target.html#zero-etl-config-target-s3-tables

This was done from the console. Open the table bucket screen in the S3 Tables console and select "Enable integration."

After enabling, you can confirm that s3tablescatalog has been created with aws glue get-catalogs.

$ aws glue get-catalogs
{
    "CatalogList": [
        {
            "CatalogId": "<AccountID>:s3tablescatalog",
            "Name": "s3tablescatalog",
            "FederatedCatalog": {
                "Identifier": "arn:aws:s3tables:ap-northeast-1:<AccountID>:bucket/*",
                "ConnectionName": "aws:s3tables"
            },
            ...
        }
    ]
}

s3tablescatalog is a federated catalog that makes S3 Tables appear under the Glue Data Catalog. Rather than copying S3 Tables data and metadata to Glue, it queries the S3 Tables side at reference time.

The following article is helpful for understanding this point.

https://docs.aws.amazon.com/ja_jp/lake-formation/latest/dg/create-s3-tables-catalog.html

Create Zero-ETL Integration

In the DynamoDB console, click "Integrations > Amazon SageMaker Lakehouse."

Select Amazon DynamoDB as the source and specify the target table.

Specify the settings related to the target.

The details of each setting are as follows.

  • Target
    • Specifies the synchronization destination. You can choose from Redshift, Glue Data Catalog, and S3 Tables catalog; S3 Tables catalog was selected this time
    • The S3 Tables catalog is treated as a federated catalog (s3tablescatalog) registered under the Glue Data Catalog
  • Processing role
    • The IAM role that Glue assumes when running the integration. Specify a role with permissions such as writing to the S3 table bucket. Here, lab-zetl-glue-role created in the preliminary setup was specified
  • Namespace
    • The namespace (equivalent to a Glue database) where data will be written. It can be created even if left blank, but in that case it will be automatically generated with the name zetl_<integrationID>. Here it was set to lab_zetl

The next settings are as follows.

  • Schema unnesting
    • This setting determines how far nested maps and other structures are expanded into columns
    • The options are Unnest top level fields only (expand only top-level attributes), Unnest all fields (expand everything including nested content), and Don't unnest any fields (no expansion)
    • Proceeded with the default Unnest all fields. Nested maps are expanded into flat columns with dot-separated names (details described later)
  • Data partitioning
    • This setting determines which columns are used to partition the stored target table
    • There are two options: Use primary keys from DynamoDB tables (use DynamoDB keys) and Specify custom partition keys (specify arbitrary columns)
  • Target table name
    • The name of the table created at the target. Set to lab_orders

When configuring the target, "Fix data catalog resource policy" was displayed. This message indicates that the target Glue catalog does not have a resource policy to accept the Zero-ETL integration. When "Fix it for me" was selected and proceeding to the next step, AWS Glue automatically set the necessary policy.

After configuration, checking with aws glue get-resource-policy showed the following policy had been created.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::<AccountID>:root" },
      "Action": "glue:CreateInboundIntegration",
      "Resource": "arn:aws:glue:ap-northeast-1:<AccountID>:catalog/s3tablescatalog/lab-zetl-analytics"
    },
    {
      "Effect": "Allow",
      "Principal": { "Service": "glue.amazonaws.com" },
      "Action": "glue:AuthorizeInboundIntegration",
      "Resource": "arn:aws:glue:ap-northeast-1:<AccountID>:catalog/s3tablescatalog/lab-zetl-analytics"
    }
  ]
}

This is the Glue-side resource policy that allows accepting the integration.

Finally, configure the integration settings.

  • Data encryption
    • Settings for the key used to encrypt data. The default Use AWS managed KMS key encrypts with an AWS-managed key
    • Selecting Select a custom encryption key allows you to use a KMS key you created yourself. In that case, Glue and the processing role need permissions to use that key
  • On-demand snapshot
    • When checked, only a one-time full copy is made, with no subsequent continuous synchronization. After the copy is complete, deleting the integration is recommended (to avoid reaching the per-account integration limit)
    • Since I want to verify differential synchronization, this is not checked
  • Refresh Interval
    • The frequency of updates from the source to the target. Can be specified in days, hours, or minutes, with a minimum of 15 minutes
    • Set to the minimum of 15 minutes here

Narrow the Source-Side Policy to the Integration ARN

After creating the integration, the integration ARN becomes known, so narrow the DynamoDB resource policy to the integration ARN.

aws glue describe-integrations --query 'Integrations[].IntegrationArn'

Replace the condition that was ArnLike with integration:* to StringEquals with the integration ARN. The complete policy after replacement is as follows.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowGlueZeroEtlExport",
      "Effect": "Allow",
      "Principal": {
        "Service": "glue.amazonaws.com"
      },
      "Action": [
        "dynamodb:ExportTableToPointInTime",
        "dynamodb:DescribeTable",
        "dynamodb:DescribeExport"
      ],
      "Resource": "*",
      "Condition": {
        "StringEquals": {
          "aws:SourceAccount": "<AccountID>",
          "aws:SourceArn": "arn:aws:glue:ap-northeast-1:<AccountID>:integration:<IntegrationID>"
        }
      }
    }
  ]
}

Saved the above as /tmp/policy.json and applied it with put-resource-policy.

aws dynamodb put-resource-policy \
  --resource-arn arn:aws:dynamodb:ap-northeast-1:<AccountID>:table/lab-orders \
  --policy file:///tmp/policy.json

Verifying the Initial Load

After creating the integration, the lab_zetl namespace and the lab_orders table were created after some time. The namespace also contains zetl_integration_table_state, which is an internal Zero-ETL table.

The ingestion status can be checked in CloudWatch Logs. Part of the initial load log was as follows.

{
    "level": "VERBOSE",
    "messageType": "IngestionStarted",
    "details": { "tableName": "lab_orders", "loadType": "SEED", "message": "Successfully started ingestion" }
}
{
    "level": "INFO",
    "messageType": "IngestionTableStatistics",
    "details": { "tableName": "lab_orders", "loadType": "SEED", "insertCount": 1001, "updateCount": 0, "deleteCount": 0 }
}
{
    "level": "VERBOSE",
    "messageType": "IngestionCompleted",
    "details": {
        "tableName": "lab_orders",
        "loadType": "SEED",
        "message": "Successfully completed ingestion",
        "lastSyncedTimestamp": 1792483069544,
        "consumedResourceUnits": "5",
        "consumedDpuHours": "0.2403"
    }
}

The loadType is SEED, indicating the initial full synchronization, which took about 3 minutes from start to finish. The count also matches the DynamoDB record count (insertCount is 1001: 1,000 regular records and 1 nested record).

Let me also verify the count in Athena. Select s3tablescatalog/lab-zetl-analytics as the data source and lab_zetl as the database, then execute.

SELECT COUNT(*) FROM "lab_zetl"."lab_orders";

The same 1,001 records as on the DynamoDB side were returned.

Verifying Type Conversion

Run DESCRIBE in Athena to check the column types.

DESCRIBE lab_zetl.lab_orders;

Here is a partial excerpt of the results.

quantity	double
order_id	string
is_gift	boolean
ordered_at	string
shipping.method	string
tags	array<string>
items	array<string>
shipping.fee	double
mixed_list	array<string>
shipping.address.city	string
discount_rate	double
scores	array<double>
shipping.address.zip	string

The correspondence between DynamoDB data types and column types in Athena and Snowflake was as follows. The Snowflake side was verified in the steps described later.

DynamoDB Type Attribute Athena Snowflake
S order_id string VARCHAR(134217728)
N (integer) quantity double FLOAT
N (decimal) discount_rate double FLOAT
BOOL is_gift boolean BOOLEAN
NULL gift_note No column No column
M (nested) shipping Expanded into flat columns Expanded into flat columns
L (list of maps) items array<string> ARRAY(VARCHAR(134217728))
L (mixed types) mixed_list array<string> ARRAY(VARCHAR(134217728))
SS (string set) tags array<string> ARRAY(VARCHAR(134217728))
NS (number set) scores array<double> ARRAY(FLOAT)
S (datetime format string) ordered_at string VARCHAR(134217728)

The main characteristics are as follows.

  • All numbers become double
  • Nested maps are expanded into flat columns with dot-separated names

As an example, let's look at shipping (a 2-level nested map) for order_id ORD-NESTED-0001.

"shipping": {
  "M": {
    "method":  { "S": "宅配便" },
    "fee":     { "N": "500" },
    "address": { "M": { "zip": { "S": "100-0001" }, "city": { "S": "千代田区" } } }
  }
}

This single attribute is expanded into the following 4 columns.

SELECT order_id, "shipping.method", "shipping.address.city"
FROM "lab_zetl"."lab_orders"
WHERE order_id = 'ORD-NESTED-0001';
#	order_id	shipping.method	shipping.address.city
1	ORD-NESTED-0001	宅配便	千代田区

A map like shipping has its keys become part of the column name, expanded into columns with dot-connected names like shipping.method. Since the column names contain dots, they must be enclosed in double quotes in queries.

  • Elements inside lists are not expanded

As a concrete example, let's look at a list of maps (items) and a list with mixed types (mixed_list).

"items": {
  "L": [
    { "M": { "sku": { "S": "BK-001" }, "qty": { "N": "1" } } },
    { "M": { "sku": { "S": "BK-002" }, "qty": { "N": "1" } } }
  ]
},
"mixed_list": { "L": [ { "S": "a" }, { "N": "1" } ] }

Maps inside lists are not expanded; instead, each list element becomes a string with a type symbol (such as {"M":{...}} or {"S":"a"}).

SELECT order_id,
        items,
        items[1] AS first_item,
        mixed_list
FROM "lab_zetl"."lab_orders"
WHERE order_id = 'ORD-NESTED-0001';
#	order_id	items	first_item	mixed_list
1	ORD-NESTED-0001	[{"M":{"qty":{"N":"1"},"sku":{"S":"BK-001"}}}, {"M":{"qty":{"N":"1"},"sku":{"S":"BK-002"}}}]	{"M":{"qty":{"N":"1"},"sku":{"S":"BK-001"}}}	[{"S":"a"}, {"N":"1"}]

In addition, sets (SS, NS) become regular arrays.

Verifying Updates and Deletions

After the initial load, perform updates and deletions on the DynamoDB table and verify the behavior of differential synchronization (CDC).

Execute Updates and Deletions

Among the sample data, the status of 100 records was updated to "Delivered," and the first 50 of those were deleted.

Confirm Synchronization Completion

After about 15 minutes, checking CloudWatch Logs showed that ingestion with loadType of CDC had completed.

{
    "messageType": "IngestionTableStatistics",
    "details": { "tableName": "lab_orders", "loadType": "CDC", "insertCount": 0, "updateCount": 34, "deleteCount": 50 }
}
{
    "messageType": "IngestionCompleted",
    "details": {
        "tableName": "lab_orders",
        "loadType": "CDC",
        "lastSyncedTimestamp": 1792485042578,
        "consumedResourceUnits": "10",
        "consumedDpuHours": "0.5750"
    }
}

The deleteCount was 50 as expected, but updateCount was 34.
Among the 50 records targeted for update, 16 items already had the status "Delivered." 50 − 16 = 34, which matches updateCount, suggesting that updates to the same value were not counted as changes.

Verify in Athena

Check the total count and count by status in Athena.

SELECT COUNT(*) FROM "lab_zetl"."lab_orders";
SELECT status, COUNT(*) FROM "lab_zetl"."lab_orders" GROUP BY status;
status        count
受付          149
キャンセル    182
配達完了      250
出荷済み      202
出荷準備中    168

The count was 951 records, and the total by status was also 951 records.

Also verify the deleted and updated items.

-- Deleted items: should return 0 records
SELECT COUNT(*) FROM "lab_zetl"."lab_orders"
WHERE order_id BETWEEN 'ORD-00000000' AND 'ORD-00000049';
#	_col0
1	0
-- Updated items: should return 50 records, all with status "Delivered"
SELECT status, COUNT(*) FROM "lab_zetl"."lab_orders"
WHERE order_id BETWEEN 'ORD-00000050' AND 'ORD-00000099'
GROUP BY status;
#	status	_col1
1	配達完了	50

It was confirmed that both were reflected as expected.

Verifying Insertions and New Attribute Additions

Next, verify how insertions and attributes added later are handled. The following changes were made in DynamoDB.

Operation Target Content
Update ORD-00000100 Changed status to "SF Confirmed" and added new attribute updated_at
Insert ORD-SF-0001 Item with new attributes coupon_code and updated_at
Insert ORD-SF-0002 Item with only updated_at
Delete ORD-00000101 Delete the item

updated_at was set to the write time (UTC).

After synchronization is complete, verify in Athena.

DESCRIBE lab_zetl.lab_orders;

Here is a partial excerpt of the output, showing that columns for the new attributes were automatically added. Columns are added at the end, and the type was string.

...
coupon_code	string
updated_at	string

Also verify the records.

SELECT order_id, status, updated_at, coupon_code
FROM "lab_zetl"."lab_orders"
WHERE order_id IN ('ORD-00000100', 'ORD-00000101', 'ORD-SF-0001', 'ORD-SF-0002')
ORDER BY order_id;
order_id        status    updated_at            coupon_code
ORD-00000100    SF確認    2026-10-20T09:16:52Z
ORD-SF-0001     受付      2026-10-20T09:16:52Z  WELCOME10
ORD-SF-0002     受付      2026-10-20T09:16:52Z

As shown above, it was confirmed that the update (ORD-00000100 changed to "SF Confirmed"), insertions (ORD-SF-0001, ORD-SF-0002), and deletion (no row for ORD-00000101) were all reflected. Items without coupon_code become NULL.

Referencing from Snowflake

Up to this point, tables have been synchronized to S3 Tables. Let's verify whether the same tables can be referenced from Snowflake as well.

As of October 2026, the feature to connect directly to the S3 Tables Iceberg REST endpoint from Snowflake is generally available, so let's try connecting via this endpoint. With this method, it connects to S3 Tables without going through Glue, using SigV4 authentication and Vended credentials.

https://docs.snowflake.com/en/release-notes/2026/other/2026-08-10-amazon-s3-tables-iceberg-rest-catalog-integration-ga

https://docs.snowflake.com/en/user-guide/tables-iceberg-configure-catalog-integration-rest-s3tables

Create an IAM Role

Create an IAM role for Snowflake to access S3 Tables. The IAM user ARN and external ID on the Snowflake side will be updated after the integration is created.

Here, only the permissions required for reading were granted.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3tables:GetTableBucket",
        "s3tables:ListNamespaces",
        "s3tables:GetNamespace",
        "s3tables:ListTables",
        "s3tables:GetTable",
        "s3tables:GetTableMetadataLocation",
        "s3tables:GetTableData"
      ],
      "Resource": [
        "arn:aws:s3tables:ap-northeast-1:<AccountID>:bucket/lab-zetl-analytics",
        "arn:aws:s3tables:ap-northeast-1:<AccountID>:bucket/lab-zetl-analytics/table/*"
      ]
    }
  ]
}

Create a Catalog Integration

Create a catalog integration on the Snowflake side.

USE ROLE ACCOUNTADMIN;

CREATE OR REPLACE CATALOG INTEGRATION lab_s3tables_int
  CATALOG_SOURCE = ICEBERG_REST
  TABLE_FORMAT = ICEBERG
  REST_CONFIG = (
    CATALOG_URI = 'https://s3tables.ap-northeast-1.amazonaws.com/iceberg'
    CATALOG_API_TYPE = AWS_S3TABLES
    ACCESS_DELEGATION_MODE = VENDED_CREDENTIALS
    CATALOG_NAME = 'arn:aws:s3tables:ap-northeast-1:<AccountID>:bucket/lab-zetl-analytics'
  )
  REST_AUTHENTICATION = (
    TYPE = SIGV4
    SIGV4_IAM_ROLE = 'arn:aws:iam::<AccountID>:role/lab-zetl-snowflake-role'
    SIGV4_SIGNING_REGION = 'ap-northeast-1'
  )
  ENABLED = TRUE;

Run DESCRIBE CATALOG INTEGRATION to check the IAM user ARN and external ID.

DESCRIBE CATALOG INTEGRATION lab_s3tables_int;

Replace the Trust Policy

Set the two values noted in the steps above in the trust policy of the IAM role.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": { "AWS": "<Snowflake IAM User ARN>" },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": { "sts:ExternalId": "<external ID>" }
      }
    }
  ]
}

Create a Catalog-Linked Database

Create a catalog-linked database targeting only the lab_zetl namespace.

CREATE OR REPLACE DATABASE lab_zetl_db
  LINKED_CATALOG = (
    CATALOG = 'lab_s3tables_int'
    ALLOWED_NAMESPACES = ('lab_zetl')
  );

Verify the Tables

> SHOW SCHEMAS IN DATABASE lab_zetl_db;
+-------------------------------+--------------------+------------+------------+---------------+--------------+-----------------------------------------------------------+---------+----------------+-----------------+---------------------------------+-------------------------------+------------------------+-------------------+-----------+                                                     
| created_on                    | name               | is_default | is_current | database_name | owner        | comment                                                   | options | retention_time | owner_role_type | classification_profile_database | classification_profile_schema | classification_profile | object_visibility | is_nested |                                                     
|-------------------------------+--------------------+------------+------------+---------------+--------------+-----------------------------------------------------------+---------+----------------+-----------------+---------------------------------+-------------------------------+------------------------+-------------------+-----------|                                                     
| 2026-10-20 18:09:09.943 +0900 | INFORMATION_SCHEMA | N          | N          | LAB_ZETL_DB   |              | Views describing the contents of schemas in this database |         | 7              |                 | NULL                            | NULL                          | NULL                   | NULL              | false     |
| 2026-10-20 18:09:00.958 +0900 | lab_zetl           | N          | N          | LAB_ZETL_DB   | ACCOUNTADMIN |                                                           |         | 7              | ROLE            | NULL                            | NULL                          | NULL                   | NULL              | false     |
+-------------------------------+--------------------+------------+------------+---------------+--------------+-----------------------------------------------------------+---------+----------------+-----------------+---------------------------------+-------------------------------+------------------------+-------------------+-----------+

> SHOW ICEBERG TABLES IN SCHEMA lab_zetl_db."lab_zetl";
SHOW SCHEMAS IN DATABASE lab_zetl_db;
+-------------------------------+--------------------+------------+------------+---------------+--------------+-----------------------------------------------------------+---------+----------------+-----------------+---------------------------------+-------------------------------+------------------------+-------------------+-----------+                                                     
| created_on                    | name               | is_default | is_current | database_name | owner        | comment                                                   | options | retention_time | owner_role_type | classification_profile_database | classification_profile_schema | classification_profile | object_visibility | is_nested |                                                     
|-------------------------------+--------------------+------------+------------+---------------+--------------+-----------------------------------------------------------+---------+----------------+-----------------+---------------------------------+-------------------------------+------------------------+-------------------+-----------|                                                     
| 2026-10-20 18:09:09.943 +0900 | INFORMATION_SCHEMA | N          | N          | LAB_ZETL_DB   |              | Views describing the contents of schemas in this database |         | 7              |                 | NULL                            | NULL                          | NULL                   | NULL              | false     |
| 2026-10-20 18:09:00.958 +0900 | lab_zetl           | N          | N          | LAB_ZETL_DB   | ACCOUNTADMIN |                                                           |         | 7              | ROLE            | NULL                            | NULL                          | NULL                   | NULL              | false     |
+-------------------------------+--------------------+------------+------------+---------------+--------------+-----------------------------------------------------------+---------+----------------+-----------------+---------------------------------+-------------------------------+------------------------+-------------------+-----------+

SHOW ICEBERG TABLES IN SCHEMA lab_zetl_db."lab_zetl";
+-------------------------------+------------------------------+---------------+-------------+--------------+----------------------+------------------+--------------------+------------------------------+-------------------+---------------+--------------------+---------+-----------------+--------------+-------------------+-------------------------------------------------------------------------------------------------------------------------------------------------+------------------+---------------------------+------------------------------+                                         
| created_on                    | name                         | database_name | schema_name | owner        | external_volume_name | catalog_name     | iceberg_table_type | catalog_table_name           | catalog_namespace | base_location | can_write_metadata | comment | owner_role_type | name_mapping | catalog_sync_name | auto_refresh_status                                                                                                                             | partition_specs  | current_partition_spec_id | iceberg_table_format_version |                                         
|-------------------------------+------------------------------+---------------+-------------+--------------+----------------------+------------------+--------------------+------------------------------+-------------------+---------------+--------------------+---------+-----------------+--------------+-------------------+-------------------------------------------------------------------------------------------------------------------------------------------------+------------------+---------------------------+------------------------------|
| 2026-10-20 18:09:02.741 +0900 | lab_orders                   | LAB_ZETL_DB   | lab_zetl    | ACCOUNTADMIN | <invalid>            | LAB_S3TABLES_INT | UNMANAGED          | lab_orders                   | lab_zetl          | NULL          | Y                  |         | ROLE            | NULL         |                   | {"currentSnapshotId":null,"lastSnapshotTime":null,"pendingSnapshotCount":0,"executionState":"RUNNING","lastUpdatedTime":"1970-01-01T00:00:00Z"} | [ {              |                         0 |                            2 |
|                               |                              |               |             |              |                      |                  |                    |                              |                   |               |                    |         |                 |              |                   |                                                                                                                                                 |   "spec-id" : 0, |                           |                              |
|                               |                              |               |             |              |                      |                  |                    |                              |                   |               |                    |         |                 |              |                   |                                                                                                                                                 |   "fields" : [ ] |                           |                              |
|                               |                              |               |             |              |                      |                  |                    |                              |                   |               |                    |         |                 |              |                   |                                                                                                                                                 | } ]              |                           |                              |
| 2026-10-20 18:09:02.741 +0900 | zetl_integration_table_state | LAB_ZETL_DB   | lab_zetl    | ACCOUNTADMIN | <invalid>            | LAB_S3TABLES_INT | UNMANAGED          | zetl_integration_table_state | lab_zetl          | NULL          | Y                  |         | ROLE            | NULL         |                   | {"currentSnapshotId":null,"lastSnapshotTime":null,"pendingSnapshotCount":0,"executionState":"RUNNING","lastUpdatedTime":"1970-01-01T00:00:00Z"} | [ {              |                         0 |                            2 |
|                               |                              |               |             |              |                      |                  |                    |                              |                   |               |                    |         |                 |              |                   |                                                                                                                                                 |   "spec-id" : 0, |                           |                              |
|                               |                              |               |             |              |                      |                  |                    |                              |                   |               |                    |         |                 |              |                   |                                                                                                                                                 |   "fields" : [ ] |                           |                              |
|                               |                              |               |             |              |                      |                  |                    |                              |                   |               |                    |         |                 |              |                   |                                                                                                                                                 | } ]              |                           |                              |
+-------------------------------+------------------------------+---------------+-------------+--------------+----------------------+------------------+--------------------+------------------------------+-------------------+---------------+--------------------+---------+-----------------+--------------+-------------------+-------------------------------------------------------------------------------------------------------------------------------------------------+------------------+---------------------------+------------------------------+

Let's check the row count and the count by status.

USE WAREHOUSE <warehouse name>;

> SELECT COUNT(*) FROM lab_zetl_db."lab_zetl"."lab_orders";
+----------+                                                                                                                                                                                        
| COUNT(*) |                                                                                                                                                                                        
|----------|                                                                                                                                                                                        
|      951 |                                                                                                                                                                                        
+----------+

SELECT "status", COUNT(*)
FROM lab_zetl_db."lab_zetl"."lab_orders"
GROUP BY "status";
+------------+----------+                                                                                                                                                                           
| status     | COUNT(*) |                                                                                                                                                                           
|------------+----------|                                                                                                                                                                           
| Shipped    |      202 |                                                                                                                                                                           
| Delivered  |      250 |                                                                                                                                                                           
| Preparing  |      168 |
| Received   |      149 |
| Cancelled  |      182 |
+------------+----------+

Both the total row count and the count by status matched the Athena results.

The column types were as shown in the table described above.

DESCRIBE TABLE lab_zetl_db."lab_zetl"."lab_orders";

For reference, the fact that the elements of items remain as DynamoDB JSON strings was also the same as in Athena.

SELECT "order_id", "shipping.method", "shipping.address.city", "tags", "scores", "items"
FROM lab_zetl_db."lab_zetl"."lab_orders"
WHERE "order_id" = 'ORD-NESTED-0001';
+-----------------+-----------------+-----------------------+-------------+--------------------------+-----------------------------------------------------------------+                            
| order_id        | shipping.method | shipping.address.city | tags        | scores                   | items                                                           |                            
|-----------------+-----------------+-----------------------+-------------+--------------------------+-----------------------------------------------------------------|                            
| ORD-NESTED-0001 | Home Delivery   | Chiyoda               | [           | [                        | [                                                               |                            
|                 |                 |                       |   "Gift",   |   2.500000000000000e+00, |   "{\"M\":{\"qty\":{\"N\":\"1\"},\"sku\":{\"S\":\"BK-001\"}}}", |                            
|                 |                 |                       |   "Urgent"  |   1.000000000000000e+00  |   "{\"M\":{\"qty\":{\"N\":\"1\"},\"sku\":{\"S\":\"BK-002\"}}}"  |
|                 |                 |                       | ]           | ]                        | ]                                                               |
+-----------------+-----------------+-----------------------+-------------+--------------------------+-----------------------------------------------------------------+

After the earlier updates, inserts, and deletes, we checked in Snowflake at the same time as in Athena, and the new columns coupon_code and updated_at had also been added.

SELECT "order_id", "status", "updated_at", "coupon_code"
FROM lab_zetl_db."lab_zetl"."lab_orders"
WHERE "order_id" IN ('ORD-00000100', 'ORD-00000101', 'ORD-SF-0001', 'ORD-SF-0002')
ORDER BY "order_id";
+--------------+--------+----------------------+-------------+                                                                                                                                      
| order_id     | status | updated_at           | coupon_code |                                                                                                                                      
|--------------+--------+----------------------+-------------|                                                                                                                                      
| ORD-00000100 | SF confirmed | 2026-10-20T09:16:52Z | NULL        |                                                                                                                                      
| ORD-SF-0001  | Received   | 2026-10-20T09:16:52Z | WELCOME10   |
| ORD-SF-0002  | Received   | 2026-10-20T09:16:52Z | NULL        |
+--------------+--------+----------------------+-------------+

The delay in additions on the Snowflake side was not significant within the range we checked. However, the REFRESH_INTERVAL_SECONDS for the Snowflake catalog integration is set to the default of 30 seconds.

Conclusion

We verified the DynamoDB Zero-ETL integration targeting the S3 Tables catalog.
We confirmed the initial full sync, incremental sync for updates, deletions, and inserts, and type conversions, and were able to reference the same table from both Athena and Snowflake. If the 15-minute update frequency is acceptable, this can be used as an integration approach without any setup or operational overhead, which is a notable characteristic.
We hope this content is helpful to someone.


Snowflakeの導入支援はクラスメソッドに!

クラスメソッドでは Snowflake の導入を支援しております。
製品の詳細や支援の内容についてお気軽にお問い合わせください。

Snowflakeの詳細を見る

Share this article