SQL at the Edge with D1

Lesson 4 · Cloudflare Workers · ~15 minutes

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.

What 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:

Free Tier Limits

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.

What We're Building

A notes API with four endpoints:

MethodEndpointAction
GET/api/notesList all notes
GET/api/notes/:idGet one note
POST/api/notesCreate a note
DELETE/api/notes/:idDelete a note

Let's build it step by step.

Step 1: Create the D1 Database

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.

Step 2: Add the D1 Binding

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.

Step 3: Create a Migration

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'))
);
Why TEXT for created_at?

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.

Step 4: Apply the Migration Locally

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.

Step 5: Build the CRUD API

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 });
  },
};

How D1 Queries Work

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:

MethodUse WhenReturns
.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 } }
Why .bind() instead of string interpolation?

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.

Step 6: Test Locally

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

Step 7: Deploy to Production

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.

What you just got — for free

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.

Quick Check

Why does D1 use .bind() for query parameters instead of string interpolation?

Key Takeaways

📖 Primary Source

D1 Getting Started — the official guide to creating and querying D1 databases. Has the full API reference for prepare, bind, batch, and more.

💬 Questions? Ask me anything. For example: "How do I add an UPDATE endpoint?" or "Can D1 handle relations between tables?" — I'm your teacher, use me.
← Back Next →