CATALOGS

Cloudflare R2 and Basin, joined with everything else you already have

The Thunderduck team6 min read

The problem: moving data by default

Ask most data teams whether a dataset can be used, and the answer starts with loading it somewhere: a warehouse, then a pipeline to keep it current, then monitoring for the pipeline. Moving data has become the default, even when nothing about the question requires it. Often the data doesn't need to move at all:

  • Cost: every copy is paid for twice, once in storage and again in the compute that keeps it in sync. Reading in place is paid per query.
  • No ETL to monitor: a pipeline that doesn't exist can't fail at 3 a.m., drift from its source or need a backfill.
  • Interim dashboards: a dashboard needed for a launch or a quarter can query the sources directly, and nothing needs decommissioning when it goes away.
  • Evaluating before productionizing: find out whether a dataset is worth a pipeline by querying it first. Build the pipeline once the answer is yes.
  • Freshness: a query against the source reads the data as it is now, not as of the last load.
  • Ad hoc questions and agents: one-off investigations, and AI agents exploring over MCP, can ask across every catalog without a ticket to load anything first.
  • Residency and ownership: the data stays in its home bucket, region and account, under the access controls it already has.

Some data does need to move. But not all of it to the same place, and not as a full copy of everything.

How Thunderduck solves it: sit in the middle

Thunderduck sits between your storage and the places data gets used. Everything it's connected to can be queried in place, so the first question becomes "does this need to move at all?" rather than "how do we load this?"

When data does need to move, it should go where it will be used, in the shape that place needs, rather than everything going into one warehouse. With Thunderduck, each piece of data can land somewhere different:

  • A summary table in your application's Postgres, built from clickstream data in Cloudflare, so the product reads a small, fast table instead of the raw events.
  • Cold data in the Thunderduck lakehouse, as Iceberg in low-cost object storage, still queryable whenever someone needs it.
  • Clickstream enriched with your company's transactional data, kept as Iceberg tables in Cloudflare Basin, where Spark, Trino and other Iceberg engines can read it too.
  • A daily extract for a third-party system, which it can fetch from your storage or which can be delivered to theirs.
thunderduck — one source, three destinations
-- Enriched: Basin clickstream + Postgres customers, kept in Basin as Iceberg
CREATE TABLE lake_cf.analytics.sessions_enriched AS
SELECT s.*, c.plan, c.segment
FROM   lake_cf.raw.clickstream s
JOIN   app_pg.public.customers  c ON c.id = s.customer_id;

-- Summary: a small daily table in the application's Postgres
INSERT INTO app_pg.public.daily_engagement
SELECT date_trunc('day', ts) AS day, segment, count(DISTINCT customer_id) AS active
FROM   lake_cf.analytics.sessions_enriched
WHERE  ts >= current_date - INTERVAL 1 DAY
GROUP  BY 1, 2;

-- Cold: last year's raw events into the Thunderduck lakehouse
CREATE TABLE lake.archive.clickstream_2025 AS
SELECT * FROM lake_cf.raw.clickstream
WHERE  ts < DATE '2026-01-01'
ORDER  BY ts;

Thunderduck orchestrates the movement. Statements like these run as scheduled queries, or as dbt models in a Git-backed pipeline with its own schedule and run history. Wherever each piece ends up, it is still one catalog name away. Efficient query federation means the Postgres summary, the Basin tables and the lakehouse archive can be queried and joined together at any time, without moving anything back.

Where Cloudflare fits

More and more teams keep data in Cloudflare R2, mostly because R2 charges nothing for egress, so reading the same bytes a hundred times costs the same as reading them once. That makes it a natural home for the data Thunderduck reads in place. Today Thunderduck adds two catalog types for it: Cloudflare R2, for the files in a bucket, and Cloudflare Basin, for Iceberg tables in Cloudflare's managed catalog. Both read straight from R2, and both sit next to every other catalog you have already connected.

Setting up R2 and Basin: one form each

  • R2: the bucket name, your account ID, and an R2 API token's access key ID and secret. A read-only token scoped to that one bucket is enough.
  • Basin: the Catalog URI from the bucket's settings, and an R2 API token. Choose a read-and-write token to create tables from Thunderduck, or a read-only one to only query them.
  • Before saving, Thunderduck tests the credentials by listing the bucket or the catalog's tables, so a wrong token fails on the form instead of in your first query.
Adding a Basin catalog: paste the Catalog URI and a token.

That is the whole setup. There's no ATTACH or CREATE SECRET to write, and no keys in your SQL. The credentials go into your deployment's secret store and are resolved inside the worker that runs the query. Basin tables then show up on the Tables page, grouped by namespace, and in Studio's autocomplete.

Basin tables on the Tables page, next to every other catalog.

Then it's just another catalog

This is the part we care about most. Once connected, an R2 bucket and a Basin catalog are addressed exactly like everything else: a Basin table is catalog.namespace.table, and an R2 file is a path under the catalog name. So a single query can join a Basin table with a Postgres database, an Iceberg table in AWS Glue, and Parquet files in S3, GCS or Azure Blob, along with MySQL, ClickHouse and BigQuery. Nothing is copied into a warehouse first.

thunderduck — Basin + Postgres + Glue + GCS in one query
SELECT c.segment,
       count(*)      AS orders,
       sum(o.amount) AS revenue,
       avg(w.sessions) AS avg_sessions
FROM   lake_cf.sales.orders               o   -- Cloudflare Basin
JOIN   shop_pg.public.customers           c   -- PostgreSQL
  ON   c.id = o.customer_id
JOIN   glue.analytics.web_sessions_daily  w   -- Iceberg on AWS Glue
  ON   w.customer_id = o.customer_id AND w.day = o.order_date
WHERE  o.order_date >= DATE '2026-10-01'
  AND  o.region IN (SELECT region FROM read_parquet('gcs_lake/ref/regions.parquet'))  -- GCS
GROUP  BY c.segment;

Filters with literal values are pushed down. The date filter above runs inside the Iceberg scan, skipping files by their min/max statistics. Against a Postgres, MySQL or BigQuery table, it runs inside that database rather than pulling the table across.

Writing to Basin, from anywhere

Basin catalogs are read and write, so R2 can also be where results land. CREATE TABLE ... AS SELECT and INSERT work as usual, and the source can be any catalog: load a CSV that's sitting in R2, snapshot a MySQL table, or roll up ClickHouse events into an Iceberg table that Spark, Trino or anything else that speaks Iceberg can read.

thunderduck — land data in Basin from other catalogs
CREATE SCHEMA IF NOT EXISTS lake_cf.sales;

-- A CSV in an R2 bucket, now an Iceberg table
CREATE TABLE lake_cf.sales.price_list AS
SELECT * FROM read_csv('cloudflare_files/exports/price_list.csv');

-- Yesterday's orders from MySQL
INSERT INTO lake_cf.sales.orders
SELECT * FROM shop_mysql.shop.orders
WHERE  created_at >= current_date - INTERVAL 1 DAY;

-- ClickHouse events, rolled up per day
CREATE TABLE lake_cf.sales.events_daily AS
SELECT date_trunc('day', ts) AS day, event, count(*) AS n
FROM   events_ch.default.events
GROUP  BY 1, 2;

The same tables are available to everything else that talks to Thunderduck: Studio, dbt pipelines, the JDBC, Python and .NET clients, and the MCP endpoint. An agent connected over MCP sees the Basin catalog alongside the rest and can join across them, inside the same cost checks as any other query.

What to know

  • An R2 catalog holds files, not tables, so it shows no table list. Query it with read_parquet, read_csv or read_json.
  • DELETE and UPDATE write Iceberg delete files, and DuckDB can't run them on partitioned tables yet. MERGE isn't available.
  • R2 buckets in a jurisdiction (EU, FedRAMP) aren't supported yet.
  • Compaction for Basin tables is handled by Cloudflare, not by Thunderduck's LakeOps page.

Setup, step by step with screenshots, is in the docs: https://docs.thunderduck.ai/catalogs/cloudflare/

Connect your data and ask it something.

Credentials to AI-queryable in about five minutes. No pipelines, no data movement.