Not every bucket has Iceberg and a Glue catalog behind it, most of the time what you have is a folder full of CSV files that some system drops every night, and someone wants to query them from Snowflake. This is the simpler version of reading Iceberg tables from S3 with Glue as the catalog. No catalog, no external volume, no metadata files. Four pieces: an IAM role, a storage integration, a stage with a file format, and the external table on top of it. The files stay where they are.

1. Create the IAM role

Same idea as the Iceberg guide, but without the Glue permissions. Snowflake only needs to list the bucket and read the files. Leave the trust policy for step 3, you get the values from Snowflake first.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:GetObjectVersion",
        "s3:ListBucket",
        "s3:GetBucketLocation"
      ],
      "Resource": [
        "arn:aws:s3:::YOUR_BUCKET",
        "arn:aws:s3:::YOUR_BUCKET/*"
      ]
    }
  ]
}

2. Create the storage integration

For plain files you use a storage integration instead of an external volume. STORAGE_ALLOWED_LOCATIONS limits which prefixes it can reach, so keep it as narrow as you can, a bucket root here is an invitation for somebody to point a stage at the wrong folder later. Run the DESC right after.

CREATE OR REPLACE STORAGE INTEGRATION s3_csv_int
  TYPE                      = EXTERNAL_STAGE
  STORAGE_PROVIDER          = 'S3'
  ENABLED                   = TRUE
  STORAGE_AWS_ROLE_ARN      = 'arn:aws:iam::ACCOUNT_ID:role/YOUR_ROLE'
  STORAGE_ALLOWED_LOCATIONS = ('s3://YOUR_BUCKET/sales/');

DESC STORAGE INTEGRATION s3_csv_int;

Copy STORAGE_AWS_IAM_USER_ARN and STORAGE_AWS_EXTERNAL_ID from the output.

3. Update the IAM trust policy

Put both values in the trust relationship of the role. Without the external ID the setup fails, and that is how it should be.

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "AWS": "STORAGE_AWS_IAM_USER_ARN"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "STORAGE_AWS_EXTERNAL_ID"
        }
      }
    }
  ]
}

4. Create the file format and the stage

This is where CSV hurts, and the only place in this guide where you need to think. There is no schema in a CSV file, Snowflake does not know about headers, quotes, empty strings or the NULL text someone’s export tool writes in place of an empty value. Tell it explicitly. Then list the stage to check Snowflake can see the files before going further.

CREATE OR REPLACE FILE FORMAT raw.csv_fmt
  TYPE                         = CSV
  SKIP_HEADER                  = 1
  FIELD_OPTIONALLY_ENCLOSED_BY = '"'
  NULL_IF                      = ('', 'NULL')
  EMPTY_FIELD_AS_NULL          = TRUE;

CREATE OR REPLACE STAGE raw.sales_stage
  URL                 = 's3://YOUR_BUCKET/sales/'
  STORAGE_INTEGRATION = s3_csv_int
  FILE_FORMAT         = raw.csv_fmt;

LIST @raw.sales_stage;

5. Create the external table

An external table over CSV gives you one column, VALUE, a variant with c1, c2, c3… one per position in the file. You map them to names and types yourself. I also take the date from the folder name as a partition column, so a query filtering on it only reads the files for that day, not the whole bucket.

Before writing the partition expression, look at what METADATA$FILENAME actually contains. It is the path from the bucket root, not from the stage, and I have lost more time than I want to admit counting slashes in the wrong place.

SELECT DISTINCT METADATA$FILENAME FROM @raw.sales_stage LIMIT 10;
-- sales/2026-09-28/orders_001.csv

CREATE OR REPLACE EXTERNAL TABLE raw.sales_orders (
  sale_date   DATE          AS TO_DATE(SPLIT_PART(METADATA$FILENAME, '/', 2), 'YYYY-MM-DD'),
  order_id    VARCHAR       AS (VALUE:c1::VARCHAR),
  customer_id VARCHAR       AS (VALUE:c2::VARCHAR),
  amount      NUMBER(12,2)  AS (VALUE:c3::NUMBER(12,2)),
  created_at  TIMESTAMP_NTZ AS (VALUE:c4::TIMESTAMP_NTZ)
)
  PARTITION BY (sale_date)
  LOCATION     = @raw.sales_stage
  FILE_FORMAT  = raw.csv_fmt
  PATTERN      = '.*[.]csv'
  AUTO_REFRESH = FALSE;

SELECT * FROM raw.sales_orders WHERE sale_date = '2026-09-28' LIMIT 100;

Columns are by position, not by name. If the source system adds a column in the middle of the file, your amount is now somebody’s postcode and nothing fails. That is the price of the simple version, and the main reason Iceberg exists.

6. Refresh the metadata

Snowflake keeps its own list of the files behind an external table. New files in S3 are invisible until that list is refreshed. For a nightly drop, a manual refresh after the load is enough. If files arrive all day, set AUTO_REFRESH = TRUE and point an S3 event notification at the notification_channel from SHOW EXTERNAL TABLES, same mechanism as in the Snowpipe guide.

ALTER EXTERNAL TABLE raw.sales_orders REFRESH;

The files stay in S3. The schema lives in Snowflake. Nobody else has to know.


Full script

-- Step 2: storage integration
CREATE OR REPLACE STORAGE INTEGRATION s3_csv_int
  TYPE                      = EXTERNAL_STAGE
  STORAGE_PROVIDER          = 'S3'
  ENABLED                   = TRUE
  STORAGE_AWS_ROLE_ARN      = 'arn:aws:iam::ACCOUNT_ID:role/YOUR_ROLE'
  STORAGE_ALLOWED_LOCATIONS = ('s3://YOUR_BUCKET/sales/');

DESC STORAGE INTEGRATION s3_csv_int; – Copy STORAGE_AWS_IAM_USER_ARN and STORAGE_AWS_EXTERNAL_ID – Update IAM trust policy before continuing

– Step 4: file format and stage CREATE OR REPLACE FILE FORMAT raw.csv_fmt TYPE = CSV SKIP_HEADER = 1 FIELD_OPTIONALLY_ENCLOSED_BY = ‘"’ NULL_IF = (’’, ‘NULL’) EMPTY_FIELD_AS_NULL = TRUE;

CREATE OR REPLACE STAGE raw.sales_stage URL = ‘s3://YOUR_BUCKET/sales/’ STORAGE_INTEGRATION = s3_csv_int FILE_FORMAT = raw.csv_fmt;

LIST @raw.sales_stage;

– Step 5: external table SELECT DISTINCT METADATA$FILENAME FROM @raw.sales_stage LIMIT 10;

CREATE OR REPLACE EXTERNAL TABLE raw.sales_orders ( sale_date DATE AS TO_DATE(SPLIT_PART(METADATA$FILENAME, ‘/’, 2), ‘YYYY-MM-DD’), order_id VARCHAR AS (VALUE:c1::VARCHAR), customer_id VARCHAR AS (VALUE:c2::VARCHAR), amount NUMBER(12,2) AS (VALUE:c3::NUMBER(12,2)), created_at TIMESTAMP_NTZ AS (VALUE:c4::TIMESTAMP_NTZ) ) PARTITION BY (sale_date) LOCATION = @raw.sales_stage FILE_FORMAT = raw.csv_fmt PATTERN = ‘.*[.]csv’ AUTO_REFRESH = FALSE;

– Step 6: refresh after new files land ALTER EXTERNAL TABLE raw.sales_orders REFRESH;

SELECT * FROM raw.sales_orders WHERE sale_date = ‘2026-09-28’ LIMIT 100;