DuckDB + Amazon S3
This guide covers reading and writing files in Amazon S3 from DuckDB: the extensions, the secret, the SQL, and the errors you are most likely to hit, each under its exact message. Everything in the first part is plain open-source DuckDB (checked on 1.5). The last section shows the same thing on Thunderduck, where the setup is done for you.
1. Load the extensions
INSTALL httpfs; LOAD httpfs;
INSTALL aws; LOAD aws;httpfs reads and writes s3:// paths. aws provides
PROVIDER credential_chain in the next step. DuckDB autoloads both on first
use, but that is a download in the middle of your first query; install them up
front, or bake them into your image.
2. Create a secret
A secret tells DuckDB how to sign requests to S3. The one you want almost
always is credential_chain: it finds credentials the same way the AWS CLI
and SDKs do (environment variables, ~/.aws/config, SSO, an instance or
container role), so no keys appear in your SQL.
CREATE SECRET s3_creds (
TYPE s3,
PROVIDER credential_chain,
REGION 'us-east-1'
);Useful options:
| Option | What it does |
|---|---|
CHAIN 'env;config' | Only try these sources, in this order (env, config, sso, instance, sts, …) |
PROFILE 'analytics' | Use a named profile from ~/.aws/config |
REFRESH auto | Fetch new credentials when temporary ones expire, instead of failing mid-session |
SCOPE 's3://my-bucket' | Use this secret only for paths under that prefix (see several buckets) |
For a public bucket, a secret with only a region reads anonymously:
CREATE SECRET public_data (TYPE s3, PROVIDER config, REGION 'us-west-2');Static keys work too (PROVIDER config, KEY_ID '…', SECRET '…'), but then
they live in your SQL or your scripts. Prefer the chain.
3. Read files
read_parquet, read_csv and read_json take s3:// paths directly, with
wildcards:
-- one file
SELECT * FROM read_parquet('s3://my-lake/events/2026-10-01.parquet');
-- every file under a prefix
SELECT event_type, count(*) AS events
FROM read_parquet('s3://my-lake/events/**/*.parquet')
GROUP BY 1
ORDER BY 2 DESC;
-- key=value folders become columns, and filters on them skip whole folders
SELECT count(*)
FROM read_parquet('s3://my-lake/events/*/*.parquet', hive_partitioning = true)
WHERE day = '2026-10-01';A real public example you can run as is (Overture Maps, us-west-2, with the
anonymous secret above):
SELECT country, count(*) AS areas
FROM read_parquet('s3://overturemaps-us-west-2/release/2026-10-21.0/theme=divisions/type=division_area/*.parquet')
GROUP BY 1
ORDER BY 2 DESC
LIMIT 3;To see which files a pattern matches before reading them:
SELECT file FROM glob('s3://my-lake/events/**/*.parquet');4. Write files with COPY TO
-- one Parquet file
COPY (SELECT * FROM orders WHERE order_date >= DATE '2026-10-01')
TO 's3://my-lake/exports/orders_october.parquet' (FORMAT parquet);
-- one folder per day: s3://my-lake/orders/day=2026-10-01/data_0.parquet
COPY (SELECT *, order_date AS day FROM orders)
TO 's3://my-lake/orders' (FORMAT parquet, PARTITION_BY (day), OVERWRITE_OR_IGNORE);The partition column is not written inside the files (it lives in the folder
name), so PARTITION_BY on every column of a query fails with
"No column to write as all columns are specified as partition columns".
Writing needs s3:PutObject on the target prefix; reading needs
s3:GetObject, and wildcards also need s3:ListBucket.
Errors and fixes
HTTP 403 Forbidden: No credentials are provided
HTTP Error: HTTP GET error reading 'https://my-bucket.s3.us-east-1.amazonaws.com/x.parquet'
in region 'us-east-1' (HTTP 403 Forbidden)
AccessDenied: Access Denied
Authentication Failure - this is usually caused by invalid or missing credentials.
* No credentials are provided.DuckDB sent the request unsigned. Either there is no S3 secret at all, or the
only matching secret is a region-only one meant for public data. Create a
credential_chain secret (step 2), and check which secret a path will use:
FROM which_secret('s3://my-bucket/x.parquet', 's3');HTTP 403 Forbidden: Credentials are provided, but they did not work
... (HTTP 403 Forbidden)
AccessDenied: Access Denied
* Credentials are provided, but they did not work.DuckDB signed the request, and AWS refused it. In order of likelihood:
- The role or user lacks
s3:GetObjecton that object (ors3:ListBucketon the bucket, for a wildcard). - The bucket policy denies you, for example it only allows a VPC endpoint.
- The objects are encrypted with a KMS key you can't use (
kms:Decrypt). - The chain picked up different credentials than you expected. Pin it with
CHAINandPROFILE, then checkaws sts get-caller-identitywith the same profile.
Secret Validation Failure
Invalid Configuration Error: Secret Validation Failure: during `generate` using the following:
Profile: 'analytics'
Credential Chain: 'sso'CREATE SECRET … PROVIDER credential_chain checks that the chain finds
credentials when you create it, and fails here if it can't. Usually the SSO
session expired (aws sso login --profile analytics), the profile name is
wrong, or the environment variables aren't set in the process running DuckDB
(a notebook kernel or a cron job sees a different environment than your
shell).
Expired temporary credentials in a long session
Credentials from SSO, an assumed role or an instance profile expire, often
after an hour. A secret created earlier keeps the old ones, so a long-running
notebook or service starts failing with 403s. Add REFRESH auto to the secret
so DuckDB fetches fresh credentials when they expire, or recreate the secret.
Several buckets, several secrets
With more than one S3 secret, DuckDB picks the one whose SCOPE is the
longest match for the path. Give each bucket its own:
CREATE SECRET lake (TYPE s3, PROVIDER credential_chain, REGION 'us-east-1',
SCOPE 's3://my-lake');
CREATE SECRET partner (TYPE s3, PROVIDER credential_chain, PROFILE 'partner',
REGION 'eu-west-1', SCOPE 's3://partner-drop');
FROM which_secret('s3://partner-drop/2026/10/file.csv', 's3'); -- partnerThe same thing on Thunderduck
Thunderduck runs DuckDB with this setup already done. You add the bucket once as an object-storage catalog (S3, GCS, Azure or Cloudflare R2), and every query after that:
- has no extensions, secrets or keys to manage. Credentials are tested when you save the catalog and kept in a secrets manager, never in SQL.
- uses catalog paths instead of bucket URLs. The catalog name stands in for the bucket, so access is checked per catalog and per role.
- runs in its own DuckDB worker, sized from the query plan, that can only reach your own catalogs.
-- catalog "raw_files" points at s3://my-lake
SELECT event_type, count(*) AS events
FROM read_parquet('raw_files/events/**/*.parquet')
GROUP BY 1;
COPY (SELECT * FROM sales_pg.public.orders WHERE amount > 100)
TO 'raw_files/exports/orders_{today}.parquet' (FORMAT parquet);The files can also be joined with your databases and Iceberg tables in the same query. See Reading files and Exporting files for the full syntax.