Building with Cloudflare D1 and Workers
Welcome to the world of serverless databases! Cloudflare D1 pairs a lightweight SQLite engine with the global reach of Cloudflare Workers, letting you store structured data right at the edge. In this guide we’ll walk through provisioning a D1 instance, wiring it up to a Worker, and building a couple of real‑world features—all while keeping latency low and scalability high.
What is Cloudflare D1?
D1 is Cloudflare’s first‑class relational database offering, built on SQLite and fully managed on the edge network. Because it lives alongside Workers, you get sub‑millisecond round‑trips for reads and writes, no separate connection pooling, and automatic backups. Think of it as a “SQL‑as‑a‑service” that feels like a local file but scales globally.
Under the hood D1 stores each database as a single file that is replicated across Cloudflare’s data centers. This design gives you ACID guarantees while still benefiting from the edge’s low latency. The API is simple: you send SQL strings over HTTP, and D1 returns JSON‑encoded rows.
Key Benefits
- Zero‑Ops: No servers to patch, no connection pools to manage.
- Edge Proximity: Queries run in the same data center as the request.
- SQLite Compatibility: You can use familiar tools like
sqlite3locally and then migrate to D1. - Built‑in Backups: Cloudflare snapshots your data daily.
Provisioning Your First D1 Database
Start by installing the Cloudflare CLI (wrangler) if you haven’t already. The CLI handles both Workers and D1 resources, so you’ll stay in a single workflow.
# Install Wrangler (Node.js required)
npm install -g wrangler
# Log in to your Cloudflare account
wrangler login
Next, create a new D1 database. The CLI will generate a unique identifier and a secret you’ll need later for authentication.
# Create a D1 instance named "blog"
wrangler d1 create blog
# Output (example)
# ✅ Created D1 database "blog" with UUID: 123e4567‑89ab‑cdef‑0123‑456789abcdef
# 📁 Store the secret in .dev.vars or your CI system
Once the database exists, you can seed it with a schema. D1 accepts a standard SQLite dump, which means you can write migrations locally and apply them remotely.
# schema.sql
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
# Apply the schema
wrangler d1 execute blog --file=./schema.sql
Connecting a Worker to D1
Now that the database lives in the cloud, let’s write a Worker that can read and write posts. Workers run JavaScript (or TypeScript), but for the sake of this tutorial we’ll embed the script inside a Python string that you can copy into your index.js file.
# index.js (Worker entry point)
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Route: GET /posts/:slug
if (request.method === "GET" && url.pathname.startsWith("/posts/")) {
const slug = url.pathname.split("/")[2];
const { results } = await env.DB.prepare(
"SELECT title, content, created_at FROM posts WHERE slug = ?"
).bind(slug).all();
if (results.length === 0) {
return new Response("Post not found", { status: 404 });
}
return new Response(JSON.stringify(results[0]), {
headers: { "Content-Type": "application/json" },
});
}
// Route: POST /posts (JSON body)
if (request.method === "POST" && url.pathname === "/posts") {
const { title, slug, content } = await request.json();
await env.DB.prepare(
"INSERT INTO posts (title, slug, content) VALUES (?, ?, ?)"
).bind(title, slug, content).run();
return new Response("Created", { status: 201 });
}
return new Response("Not found", { status: 404 });
},
};
The magic happens in the env.DB binding. When you declare a D1 binding in your wrangler.toml, Cloudflare injects a convenient prepare API that mirrors the sqlite3 interface.
# wrangler.toml snippet
[[d1_databases]]
binding = "DB" # Accessible as env.DB in the Worker
database_name = "blog" # Name you gave the DB earlier
database_id = "123e4567‑89ab‑cdef‑0123‑456789abcdef"
Pro tip: Keep your SQL statements in separate .sql files and load them with importScripts at the top of the Worker. This makes version control easier and avoids string‑literal errors.
Running Queries from Python (Optional)
If you need a backend script—say, a nightly analytics job—you can talk to D1 directly from Python using Cloudflare’s HTTP API. The endpoint expects a POST with a JSON payload containing the SQL and optional bindings.
import os
import requests
import json
# Load secret from environment (set via .dev.vars or CI)
API_TOKEN = os.getenv("CF_API_TOKEN")
ACCOUNT_ID = os.getenv("CF_ACCOUNT_ID")
DB_ID = "123e4567‑89ab‑cdef‑0123‑456789abcdef"
def query(sql, params=None):
url = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/d1/database/{DB_ID}/query"
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json",
}
payload = {"query": sql, "params": params or []}
resp = requests.post(url, headers=headers, data=json.dumps(payload))
resp.raise_for_status()
return resp.json()["result"]
# Example: Count posts per day
result = query(
"SELECT DATE(created_at) as day, COUNT(*) as count FROM posts GROUP BY day ORDER BY day DESC"
)
for row in result:
print(f"{row['day']}: {row['count']} posts")
This script demonstrates how you can blend edge‑first logic (Workers) with traditional backend processes (Python) without ever managing a separate database server.
Real‑World Use Case: URL Shortener
One of the classic examples for a lightweight relational store is a URL shortener. The schema is tiny, the traffic pattern is read‑heavy, and D1’s edge locality shines.
# schema.sql for URL shortener
CREATE TABLE links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT UNIQUE NOT NULL,
target TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
clicks INTEGER DEFAULT 0
);
The Worker logic has two parts: a POST endpoint to create a new short link, and a catch‑all GET that redirects based on the slug. Notice the atomic UPDATE ... SET clicks = clicks + 1 which avoids race conditions.
# index.js (URL shortener)
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = url.pathname.slice(1); // Remove leading /
// Create new short link
if (request.method === "POST" && path === "create") {
const { target, slug } = await request.json();
await env.DB.prepare(
"INSERT INTO links (slug, target) VALUES (?, ?)"
).bind(slug, target).run();
return new Response(JSON.stringify({ slug }), { status: 201 });
}
// Redirect logic
const { results } = await env.DB.prepare(
"SELECT target FROM links WHERE slug = ?"
).bind(path).all();
if (results.length === 0) {
return new Response("Not found", { status: 404 });
}
// Increment click counter
await env.DB.prepare(
"UPDATE links SET clicks = clicks + 1 WHERE slug = ?"
).bind(path).run();
return Response.redirect(results[0].target, 302);
},
};
Deploy this Worker, and you have a globally distributed, zero‑maintenance URL shortener that can handle spikes without a single point of failure.
Pro tip: Store the slug as a base‑62 encoded integer (e.g., using parseInt(id).toString(36)) to keep URLs short while preserving uniqueness.
Performance & Cost Considerations
D1 pricing is based on read/write operations and storage size. Because Workers and D1 share the same edge location, you avoid the network egress costs that typical cloud databases incur. However, keep an eye on hot keys—if a single slug receives millions of requests per second, the underlying SQLite file can become a bottleneck.
Mitigation strategies include:
- Cache frequent lookups: Use Workers KV or Cache API for the hottest slugs.
- Sharding: Split data across multiple D1 databases by prefix (e.g., first letter of slug).
- Read‑only replicas: Duplicate a read‑only copy via scheduled backups and point heavy reads to it.
For most hobby projects and early‑stage SaaS products, the default single‑file setup is more than enough. Scale only when metrics indicate a need.
Security Best Practices
Because D1 runs at the edge, every request that reaches your Worker can potentially hit the database. Always validate and sanitize inputs. Cloudflare automatically parameterizes the prepare statements, but you should still enforce length limits and content policies.
Use Cloudflare Access or JWT verification to protect write endpoints. For public read endpoints, consider rate‑limiting with the Rate Limiting service to thwart abuse.
Pro tip: Store the D1 secret (used by the Python script) in Cloudflare Workers Secrets (wrangler secret put CF_API_TOKEN) so it never appears in source control.
Testing Locally with Wrangler
Before pushing to production, you can spin up a local D1 instance using wrangler dev. The CLI creates an in‑memory SQLite file that mimics the edge environment, letting you iterate quickly.
# Start a local dev server
wrangler dev
# In another terminal, run the Python script against the local DB
export CF_API_TOKEN="dummy"
export CF_ACCOUNT_ID="local"
python analytics.py
Note that the local dev mode does not enforce the same quota limits as production, so you can test bulk inserts without hitting rate caps.
Advanced Patterns: Transactional Workflows
SQLite supports full transactions, and D1 exposes them via the exec method. This is useful when you need to perform multiple related writes atomically—for example, a blog platform that creates a post and its tags in a single operation.
# Worker snippet for transactional post creation
export default {
async fetch(request, env) {
if (request.method !== "POST") return new Response("Method not allowed", {status:405});
const { title, slug, content, tags } = await request.json();
const tx = env.DB.begin(); // Start transaction
try {
const { results } = await tx.prepare(
"INSERT INTO posts (title, slug, content) VALUES (?, ?, ?)"
).bind(title, slug, content).run();
const postId = results?.lastInsertRowid;
for (const tag of tags) {
await tx.prepare(
"INSERT INTO post_tags (post_id, tag) VALUES (?, ?)"
).bind(postId, tag).run();
}
await tx.commit(); // Commit only if all inserts succeed
return new Response("Post created", {status:201});
} catch (e) {
await tx.rollback(); // Roll back on any error
return new Response("Failed to create post", {status:500});
}
},
};
This pattern guarantees that either all rows are persisted or none, preserving data integrity even under high concurrency.
Monitoring and Observability
Cloudflare provides built-in metrics for Workers and D1, visible in the dashboard under “Analytics.” Track request count, latency, and error rates per route. For deeper insight, emit custom logs via console.log in your Worker; those logs can be piped to third‑party services using Workers’ fetch to a logging endpoint.
Example of structured logging:
console.log(JSON.stringify({
event: "post_created",
slug: slug,
userAgent: request.headers.get("User-Agent"),
timestamp: Date.now()
}));
When combined with Cloudflare Logpush, you can ship these logs to Elasticsearch, Splunk, or a serverless data warehouse for real‑time dashboards.
Backup & Restore Strategies
D1 automatically snapshots your database once per day. You can download a snapshot via the API, which is handy for migrations or disaster recovery.
# Download latest snapshot
snapshot_url = f"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/d1/database/{DB_ID}/backup"
resp = requests.get(snapshot_url, headers={"Authorization": f"Bearer {API_TOKEN}"})
with open("backup.sqlite", "wb") as f:
f.write(resp.content)
To restore, simply upload the file to a new D1 instance using the wrangler d1 import command. This makes moving between environments (dev, staging, prod) seamless.
Common Pitfalls & How to Avoid Them
- Large Result Sets: D1 caps the number of rows returned per query (default 10,000). Paginate using
LIMITandOFFSETor keyset pagination. - Schema Changes: SQLite has limited ALTER TABLE support. When adding columns, use
ADD COLUMNwith a default value, or create a new table and copy data. - Blocking Operations: Avoid long‑running loops inside Workers; they share CPU time with other requests. Offload heavy processing to a background job (e.g., a Cron Trigger Worker).