This site is not affiliated with or endorsed by Cloudflare, Inc. It simply showcases experiments built using Cloudflare services.
Cloudflare Experiments
Storage & Data

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 / SHOW only (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 SELECT and SHOW are 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.

DependenciesNoneBindingsNonePlatformFetch API
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

Click the deploy button

Deploy to Cloudflare Workers

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_TOKEN

Set 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 dev
curl "http://localhost:8787/query?q=SELECT%201"

Without secrets, responses use mode: "demo".

Configuration

  • Secrets CLOUDFLARE_ACCOUNT_ID, R2_SQL_AUTH_TOKEN
  • Var WAREHOUSE (or R2_BUCKET_NAME) — default demo-warehouse

Cloudflare Features Used

On this page