R2 SQL Query
Query Apache Iceberg tables via the Cloudflare R2 SQL HTTP API - analytics SQL over object storage from a Worker
Query Apache Iceberg tables managed by R2 Data Catalog using the R2 SQL HTTP API. There is no Workers binding - this experiment uses account secrets and fetch. Without credentials it returns sample rows with mode: "demo".
Features
SELECT/SHOWonly (mutating SQL rejected)- Live queries against R2 SQL, or demo rows when secrets are missing
- Per-request warehouse override
API Reference
GET /query
Run a read-only SQL query.
q / query string (required)
SQL statement (SELECT or SHOW, max 4000 characters).
warehouse string (optional)
Override the warehouse / bucket name (defaults to WAREHOUSE var).
Example Request
curl "https://your-worker.workers.dev/query?q=SELECT%20*%20FROM%20default.events%20LIMIT%2010"Success Response (demo)
{
"mode": "demo",
"query": "SELECT * FROM default.events LIMIT 10",
"warehouse": "demo-warehouse",
"rows": [],
"note": "Configure CLOUDFLARE_ACCOUNT_ID and R2_SQL_AUTH_TOKEN for live R2 SQL queries"
}Error Codes
400- Missing or oversized query (INVALID_QUERY)400- Non-read-only SQL (FORBIDDEN_SQL)502- Upstream R2 SQL failure (QUERY_ERROR)
POST /query
Same behavior with a JSON body:
{
"query": "SELECT * FROM default.ecommerce LIMIT 10",
"warehouse": "optional-override"
}Use Cases
- Explore Iceberg tables written by Cloudflare Pipelines
- Prototype analytics over R2 Data Catalog without a separate warehouse
- Pair with Event Pipeline for ingest → query demos
- Validate SELECT/SHOW guardrails for public query APIs
Limitations
- Requires account ID + R2 SQL API token for live queries
- Only
SELECTandSHOWare allowed - No Workers binding - uses the HTTP API
- Demo mode returns sample rows, not real catalog data
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/r2-sql-query.
import { DEFAULT_WAREHOUSE, DEMO_ROWS, MAX_QUERY_LENGTH, R2_SQL_API_BASE,} from "../constants/defaults";import type { Env } from "../types/env";import type { QueryResponse, ValidateSqlResult } from "../types/query";const FORBIDDEN_PREFIXES = ["INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE"];/** * Allow only read-only SQL: statements that start with SELECT or SHOW. * Rejects empty/oversized queries and common mutating prefixes. */export function validateSql(input: string | undefined | null): ValidateSqlResult { if (input === undefined || input === null || typeof input !== "string") { return { ok: false, code: "INVALID_QUERY", message: "Missing or invalid query", }; } const trimmed = input.trim(); if (!trimmed) { return { ok: false, code: "INVALID_QUERY", message: "Missing or invalid query", }; } if (trimmed.length > MAX_QUERY_LENGTH) { return { ok: false, code: "INVALID_QUERY", message: `Query exceeds max length of ${MAX_QUERY_LENGTH} characters`, }; } const upper = trimmed.toUpperCase(); for (const prefix of FORBIDDEN_PREFIXES) { if ( upper.startsWith(prefix) && (upper.length === prefix.length || /\s/.test(upper[prefix.length]!)) ) { return { ok: false, code: "FORBIDDEN_SQL", message: `Only SELECT and SHOW queries are allowed; ${prefix} is forbidden`, }; } } if (!(upper.startsWith("SELECT") || upper.startsWith("SHOW"))) { return { ok: false, code: "FORBIDDEN_SQL", message: "Only SELECT and SHOW queries are allowed", }; } // Require a word boundary after SELECT/SHOW (e.g. reject SELECTOR) const firstWord = upper.split(/\s+/, 1)[0] ?? ""; if (firstWord !== "SELECT" && firstWord !== "SHOW") { return { ok: false, code: "FORBIDDEN_SQL", message: "Only SELECT and SHOW queries are allowed", }; } return { ok: true, query: trimmed };}export function isConfigured(env: Env): boolean { return Boolean(env.CLOUDFLARE_ACCOUNT_ID?.trim() && env.R2_SQL_AUTH_TOKEN?.trim());}export function resolveWarehouse(env: Env, override?: string | null): string { const fromBody = override?.trim(); if (fromBody) return fromBody; const fromEnv = env.WAREHOUSE?.trim() || env.R2_BUCKET_NAME?.trim(); return fromEnv || DEFAULT_WAREHOUSE;}function extractRows(payload: unknown): unknown[] { if (payload === null || payload === undefined) return []; if (Array.isArray(payload)) return payload; if (typeof payload === "object") { const obj = payload as Record<string, unknown>; if (Array.isArray(obj.rows)) return obj.rows; if (Array.isArray(obj.data)) return obj.data; if (Array.isArray(obj.result)) return obj.result; if (obj.result && typeof obj.result === "object") { const result = obj.result as Record<string, unknown>; if (Array.isArray(result.rows)) return result.rows; if (Array.isArray(result.data)) return result.data; } } return [];}export function demoQueryResult(query: string, warehouse: string): QueryResponse { return { mode: "demo", query, warehouse, rows: [...DEMO_ROWS], note: "Configure CLOUDFLARE_ACCOUNT_ID and R2_SQL_AUTH_TOKEN for live R2 SQL queries", };}/** * Run a validated SQL query against the R2 SQL HTTP API, or return demo rows * when account credentials are not configured. */export async function runQuery( env: Env, query: string, warehouseOverride?: string | null): Promise<QueryResponse> { const warehouse = resolveWarehouse(env, warehouseOverride); if (!isConfigured(env)) { return demoQueryResult(query, warehouse); } const accountId = env.CLOUDFLARE_ACCOUNT_ID!.trim(); const token = env.R2_SQL_AUTH_TOKEN!.trim(); const url = `${R2_SQL_API_BASE}/${encodeURIComponent(accountId)}/r2-sql/query/${encodeURIComponent(warehouse)}`; const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ query }), }); if (!res.ok) { const text = await res.text().catch(() => ""); throw new Error(text || `R2 SQL API returned ${res.status}`); } const raw: unknown = await res.json(); return { mode: "live", query, warehouse, rows: extractRows(raw), raw, };}Deployment
Configure secrets and warehouse
Enable R2 Data Catalog on a bucket, create a token with R2 SQL + catalog + storage read, then set:
npx wrangler secret put CLOUDFLARE_ACCOUNT_ID
npx wrangler secret put R2_SQL_AUTH_TOKENSet WAREHOUSE in wrangler.json vars to your bucket/warehouse name.
Test your deployment
curl "https://your-worker.workers.dev/query?q=SHOW%20TABLES"Local Development
cd apps/experiments/r2-sql-query
npm install
npm run devcurl "http://localhost:8787/query?q=SELECT%201"Without secrets, responses use mode: "demo".
Configuration
- Secrets
CLOUDFLARE_ACCOUNT_ID,R2_SQL_AUTH_TOKEN - Var
WAREHOUSE(orR2_BUCKET_NAME) - defaultdemo-warehouse
Cloudflare Features Used
- Workers - Edge compute runtime
- R2 SQL - Serverless SQL over Iceberg tables
- R2 Data Catalog - Managed Apache Iceberg catalog