Apache Iceberg is the open table format that lets Snowflake, Spark, Trino, Databricks, and a dozen other engines read and write the same tables in your own cloud storage. Snowflake's Iceberg support has matured from "read-only external tables" into a first-class table type with full DML, and the decision facing most teams in 2026 is no longer whether to use Iceberg but which kind: Snowflake-managed or externally managed.
This tutorial explains both, shows how to create each, and gives a practical rule for choosing.
The two flavours
An Iceberg table is data files (Parquet) plus metadata files (manifests, snapshots) plus a catalog that knows where the current metadata lives. Who owns the catalog determines everything.
| Snowflake-managed | Externally managed | |
|---|---|---|
| Catalog | Snowflake is the catalog | An external catalog: Snowflake Open Catalog (Apache Polaris), AWS Glue, or an Iceberg REST catalog |
| Storage | Your bucket, via an external volume | Your bucket, via an external volume |
| Writes from Snowflake | Full DML (INSERT, UPDATE, DELETE, MERGE), clustering, Time Travel | Read-only from Snowflake (Snowflake is a reader; the external engine writes) |
| Reads from other engines | Yes — Snowflake exposes the tables through Open Catalog / the Snowflake Iceberg REST catalog | Yes — that is the point |
| Performance in Snowflake | Close to native tables | Good; depends on the writer's file layout and metadata refresh |
The phrase to remember: whoever owns the catalog owns the writes.
Setup that both flavours share: an external volume
CREATE OR REPLACE EXTERNAL VOLUME lake_vol
STORAGE_LOCATIONS = (
(
NAME = 'lake-us-east-1'
STORAGE_PROVIDER = 'S3'
STORAGE_BASE_URL = 's3://acme-lakehouse/iceberg/'
STORAGE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/snowflake-lake-access'
)
);
DESC EXTERNAL VOLUME lake_vol; -- copy the Snowflake IAM user / external ID into the role's trust policy
The external volume is an account-level object; grant USAGE on it to the roles that will create Iceberg tables.
Snowflake-managed Iceberg tables
CREATE OR REPLACE ICEBERG TABLE analytics.orders_iceberg (
order_id BIGINT,
customer_id BIGINT,
order_date DATE,
amount DECIMAL(12,2),
status STRING
)
CATALOG = 'SNOWFLAKE'
EXTERNAL_VOLUME = 'lake_vol'
BASE_LOCATION = 'orders/';
INSERT INTO analytics.orders_iceberg SELECT order_id, customer_id, order_date, amount, status FROM analytics.orders;
-- Behaves like a regular table from here on
UPDATE analytics.orders_iceberg SET status = 'SHIPPED' WHERE order_id = 1001;
SELECT * FROM analytics.orders_iceberg AT (OFFSET => -60*5);
What you get: Snowflake's optimizer, micro-partition-style pruning, automatic metadata maintenance, Time Travel, and the ability to cluster. What changes versus a native table: the data lives in your bucket as standard Parquet, Fail-safe does not apply, and storage is billed by your cloud provider rather than by Snowflake.
To let Spark or Trino read the table, attach the account to Snowflake Open Catalog (Snowflake's managed Apache Polaris service) or point the external engine at Snowflake's Iceberg REST endpoint. From then on the table is one physical dataset with many readers and one writer.
Externally managed Iceberg tables
Here an external engine — typically Spark on Databricks or EMR, writing through Glue or a Polaris/REST catalog — owns the table, and Snowflake reads it.
-- 1. Catalog integration (AWS Glue example)
CREATE OR REPLACE CATALOG INTEGRATION glue_cat
CATALOG_SOURCE = GLUE
CATALOG_NAMESPACE = 'lakehouse'
TABLE_FORMAT = ICEBERG
GLUE_AWS_ROLE_ARN = 'arn:aws:iam::123456789012:role/snowflake-glue-access'
GLUE_CATALOG_ID = '123456789012'
ENABLED = TRUE;
-- 2. Create the Snowflake-side table pointing at the external catalog
CREATE OR REPLACE ICEBERG TABLE analytics.events_ext
EXTERNAL_VOLUME = 'lake_vol'
CATALOG = 'glue_cat'
CATALOG_TABLE_NAME = 'events'
AUTO_REFRESH = TRUE;
SELECT event_type, COUNT(*) FROM analytics.events_ext GROUP BY 1;
For a REST catalog (Open Catalog, Polaris, Tabular-style), CATALOG_SOURCE = ICEBERG_REST with REST_CONFIG and REST_AUTHENTICATION replaces the Glue settings. With AUTO_REFRESH = TRUE Snowflake polls the catalog for new snapshots; without it you call ALTER ICEBERG TABLE analytics.events_ext REFRESH after the writer commits.
Reads are read-only. INSERT against an externally managed table fails — by design, because two catalogs cannot both be the source of truth.
Read path versus write path
- Snowflake reads an externally managed table by fetching the current metadata from the catalog, then scanning the Parquet files directly from your bucket. Performance depends on the writer: well-compacted files with partition pruning read fast; thousands of tiny files from a streaming job do not. Ask the writing team to run compaction.
- Snowflake writes a Snowflake-managed table the same way it writes native tables — the Parquet is produced by Snowflake and the metadata is maintained by Snowflake. Other engines read the committed snapshots.
- Converting direction is supported:
ALTER ICEBERG TABLE ... CONVERT TO MANAGEDtakes an externally managed table and makes Snowflake the catalog, which is the usual endpoint of a "we started on Spark, now Snowflake is the primary engine" journey.
When Iceberg beats native tables
Use Iceberg (either flavour) when:
- Another engine must read the same data without a copy — Spark ML jobs, a Databricks team, Trino ad-hoc users, a vendor tool
- You want storage in your own bucket for contractual, cost, or exit-strategy reasons
- The dataset is large and shared across organizations or business units that do not all use Snowflake
Stay with native tables when:
- Only Snowflake reads and writes the data (native tables are slightly faster, support Fail-safe, and have fewer moving parts)
- You rely on features that are native-only in your region — check the documentation's current feature matrix for Iceberg; the gap closes every release but is not zero
- The table is small and operational simplicity matters more than openness
A common and sensible architecture: raw and shared layers on Iceberg, curated marts native, with Open Catalog exposing the Iceberg layer to everything else.
Converting an existing native table
There is no in-place conversion from a native table to an Iceberg table; you create the Iceberg table and load it:
CREATE OR REPLACE ICEBERG TABLE analytics.customers_iceberg
CATALOG = 'SNOWFLAKE' EXTERNAL_VOLUME = 'lake_vol' BASE_LOCATION = 'customers/'
AS SELECT * FROM analytics.customers;
-- Validate, then swap consumers over
SELECT COUNT(*), SUM(HASH(*)) FROM analytics.customers
UNION ALL
SELECT COUNT(*), SUM(HASH(*)) FROM analytics.customers_iceberg;
Watch for type mapping: Iceberg has no VARIANT — semi-structured columns land as STRING (JSON text) or need to be shredded into typed columns first — and NUMBER precision/scale must fit Iceberg's decimal. Check CTAS output against the source schema before loading production data.
Interop checklist
- Pin the catalog: one writer per table, documented
- Turn on compaction in the writing engine; small files are the number-one cause of slow reads
- Align time zones and timestamp precision across engines (Iceberg
timestamptzversus SnowflakeTIMESTAMP_LTZ) - Govern at the catalog where possible (Open Catalog RBAC) so access rules do not diverge per engine
PowderInsights designs open lakehouse architectures on Snowflake and Iceberg — catalog strategy, migration, and governance. Contact us to talk through yours.