You're about to build a CRUD API backed by a real SQL database — running for free, at the edge, in 300+ data centers. No credit card, no provisioned instances, no cold starts to worry about. This is D1.
D1 is Cloudflare's serverless SQLite database. It runs at the edge alongside your Workers, which means your data lives close to your users — not in a single distant data center.
Key facts about D1:
D1's free tier gives you 5 million rows read per day, 100,000 rows written per day, and 5 GB of storage. For a personal notes app, blog, or small SaaS — you'll likely never hit these.
A notes API with four endpoints:
| Method | Endpoint | Action |
|---|---|---|
| GET | /api/notes | List all notes |
| GET | /api/notes/:id | Get one note |
| POST | /api/notes | Create a note |
| DELETE | /api/notes/:id | Delete a note |
Let's build it step by step.
From your project directory, create a new D1 database:
npx wrangler d1 create my-notes-db
Wrangler will output something like this:
✅ Successfully created DB 'my-notes-db'
[[d1_databases]]
binding = "DB"
database_name = "my-notes-db"
database_id = "xxxx-xxxx-xxxx-xxxx"
Copy that database_id — you'll need it next.
Open your wrangler.jsonc and add the D1 binding so your Worker can access the database:
{
"name": "notes-api",
"main": "src/index.ts",
"compatibility_date": "2024-01-01",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-notes-db",
"database_id": "<your-database-id>"
}
]
}
The binding name (DB) is how you'll access the database in your Worker code via env.DB.
D1 uses a migration system to manage schema changes. Create your first migration:
npx wrangler d1 migrations create my-notes-db init
This creates a file at migrations/0001_init.sql. Open it and write your schema:
-- migrations/0001_init.sql
CREATE TABLE notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
SQLite doesn't have a native datetime type. The convention is to store ISO 8601 strings in TEXT columns. The datetime('now') function generates UTC timestamps in that format.
Apply the migration to your local D1 instance for development:
npx wrangler d1 migrations apply my-notes-db --local
This creates the notes table in your local SQLite file. You can now develop against it without touching production.
Here's the complete Worker. It routes requests to the appropriate handler and uses parameterized queries for safety.
// src/index.ts
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
const method = request.method;
// Route: GET /api/notes
if (method === "GET" && path === "/api/notes") {
const { results } = await env.DB.prepare(
"SELECT * FROM notes ORDER BY created_at DESC"
).all();
return Response.json(results);
}
// Route: GET /api/notes/:id
if (method === "GET" && path.startsWith("/api/notes/")) {
const id = path.split("/").pop();
const note = await env.DB.prepare(
"SELECT * FROM notes WHERE id = ?"
).bind(id).first();
if (!note) {
return Response.json({ error: "Note not found" }, { status: 404 });
}
return Response.json(note);
}
// Route: POST /api/notes
if (method === "POST" && path === "/api/notes") {
const body = await request.json() as { title: string; content: string };
if (!body.title || !body.content) {
return Response.json(
{ error: "title and content are required" },
{ status: 400 }
);
}
const result = await env.DB.prepare(
"INSERT INTO notes (title, content) VALUES (?, ?)"
).bind(body.title, body.content).run();
return Response.json(
{ id: result.meta.last_row_id, title: body.title, content: body.content },
{ status: 201 }
);
}
// Route: DELETE /api/notes/:id
if (method === "DELETE" && path.startsWith("/api/notes/")) {
const id = path.split("/").pop();
await env.DB.prepare(
"DELETE FROM notes WHERE id = ?"
).bind(id).run();
return Response.json({ deleted: true });
}
return Response.json({ error: "Not found" }, { status: 404 });
},
};
Every D1 query follows the same pattern: prepare → bind → execute.
// Prepare a statement with ? placeholders
const stmt = env.DB.prepare("SELECT * FROM notes WHERE id = ?");
// Bind values to the placeholders (in order)
const bound = stmt.bind(noteId);
// Execute and get results
const result = await bound.first(); // One row
const { results } = await bound.all(); // Many rows
const info = await bound.run(); // Write operation (INSERT/UPDATE/DELETE)
The three execution methods:
| Method | Use When | Returns |
|---|---|---|
.first() | You expect one row (or null) | Single object or null |
.all() | You expect multiple rows | { results: [...], success, meta } |
.run() | INSERT, UPDATE, DELETE | { success, meta: { changes, last_row_id } } |
Never build SQL strings with template literals like `SELECT * FROM notes WHERE id = ${id}`. The .bind() method sends parameters separately from the SQL, making SQL injection impossible. This is the same principle as prepared statements in every other database — D1 just makes it the only way to pass parameters.
Start the dev server:
npx wrangler dev
Test your endpoints with curl:
# Create a note
curl -X POST http://localhost:8787/api/notes \
-H "Content-Type: application/json" \
-d '{"title": "First note", "content": "Hello from D1!"}'
# List all notes
curl http://localhost:8787/api/notes
# Get one note
curl http://localhost:8787/api/notes/1
# Delete a note
curl -X DELETE http://localhost:8787/api/notes/1
Two steps: first apply the migration to your remote D1 database, then deploy the Worker.
# Apply migration to production D1
npx wrangler d1 migrations apply my-notes-db --remote
# Deploy the Worker
npx wrangler deploy
That's it. Your API is live at https://notes-api.<your-subdomain>.workers.dev — backed by a real SQL database, running globally, protected by Cloudflare's DDoS and SSL.
A globally-distributed SQL database with ACID transactions, automatic backups, a migration system, and an API running in 300+ data centers. No server to maintain, no connection pooling to configure, no infrastructure to manage. This is what "serverless" was supposed to mean.
Why does D1 use .bind() for query parameters instead of string interpolation?
env.DB, no credentials to manage.bind() — parameterized queries prevent SQL injection by designD1 Getting Started — the official guide to creating and querying D1 databases. Has the full API reference for prepare, bind, batch, and more.