R2 SQL Query
Query Apache Iceberg tables via the R2 SQL HTTP API
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