Cloudflare D1 — Your bun:sqlite Knowledge, Hosted

Lesson 0007 · Glossary · 1 · 2 · 3 · 4 · 5 · 6 · Next: D1 scheduled writes & queries from Bun

The win, in one line: by the end of this lesson you’ll have provisioned a real D1 database, pushed a schema to it with wrangler, and queried it from a Worker with prepare().bind().run() — the same SQL you already know, now reachable from anywhere.

Lesson 0006 gave you SQLite inside one file, on your machine. D1 is the same SQLite dialect, hosted by Cloudflare and queried over the network — no server to run, no file to back up, one URL away. If your daily chores outgrow a laptop file, D1 is the natural next step.

1. The hosted model

D1 stores data in SQLite and serves it through Cloudflare’s edge. You never open a file and never ssh anywhere: you write SQL, and Cloudflare runs it for you on a server you never see. That means the query language from Lesson 0006 — CREATE TABLE, INSERT OR REPLACE, SELECT, named and positional parameters — transfers unchanged. Only the plumbing differs. (Cloudflare docs: D1)

your code ── SQL + params ──▶ Cloudflare edge ──▶ SQLite storage
Worker or Bun script          (you never see it)   (hosted, backed up)

2. Provision a database

Install & log in once (wrangler login), then one command creates a real database in your account:

wrangler d1 create d1-demo

Wrangler prints a block exactly like this — save it:

{
  "d1_databases": [
    {
      "binding": "d1_demo",
      "database_name": "d1-demo",
      "database_id": "7613f423-…"
    }
  ]
}

binding is the variable name your code will see; database_id is how Cloudflare tracks the physical database. Put the block in your Worker’s wrangler.jsonc, and env.DB (or whatever binding name you chose) appears in your code. List what you have any time with wrangler d1 list. (Cloudflare docs: D1 get started)

3. Schema: write SQL in a file, apply it

Never teach your schema by hand one query at a time — keep it in schema.sql, the exact DDL from Lesson 0006:

CREATE TABLE IF NOT EXISTS notes (
  id TEXT PRIMARY KEY,
  title TEXT NOT NULL,
  body TEXT,
  created_at TEXT
);

Apply it anywhere with a flag:

wrangler d1 execute d1-demo --local  --file=schema.sql   # dev: a file on your disk
wrangler d1 execute d1-demo --remote --file=schema.sql   # prod: the hosted DB

--local runs against a plain SQLite file Wrangler keeps under .wrangler/state/ (instant, free, throwaway); --remote runs against the real hosted database. Same file, same SQL, two targets — your whole migration workflow in one habit. For quick probes use --command="SELECT …" instead of --file.

4. Query it from a Worker

A Worker is a tiny JS/TS file with a fetch handler. The D1 API is a mirror of Lesson 0006’s statements: prepare(), bind() (your friend from the SQL-injection defense), then .run(), .all(), or .first():

interface Env {
  DB: D1Database;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/notes") {
      const { results } = await env.DB
        .prepare("SELECT * FROM notes ORDER BY created_at")
        .all();
      return Response.json({ results });
    }

    const title = url.searchParams.get("title") ?? "untitled";
    const ins = await env.DB
      .prepare("INSERT OR REPLACE INTO notes (id, title, body, created_at) VALUES (?, ?, ?, ?)")
      .bind(crypto.randomUUID(), title, url.searchParams.get("body") ?? "", new Date().toISOString())
      .run();

    const note = await env.DB
      .prepare("SELECT * FROM notes WHERE title = ?")
      .bind(title)
      .first();

    return Response.json({ changes: ins.meta.changes, note });
  },
};

The one real difference from Lesson 0006: everything is async (it’s a network call), and .get() became .first() (still null when nothing matches). Parameters work identically — ? bound positionally, and the $name/:name/@name families bound with an object. Statements are generics, so prepare<Note>(sql).all() gives you back typed rows, same as db.query<Release> did locally.

5. Run it locally, then point it at prod

wrangler dev                 # serves the Worker on http://localhost:8787, using --local D1
curl "http://localhost:8787/?title=hello"      # → {"changes":1, "note":{…}}
curl "http://localhost:8787/notes"             # → {"results":[…]}

The binding is wired automatically; you’ll see env.DB (d1-demo) D1 Database local in the dev banner. When ready, deploy the Worker (wrangler deploy) and --remote your schema — dev and prod now share one code path.

The bun:sqlite → D1 map

bun:sqlite (lesson 0006)D1 (this lesson)
open a file: new Database("x.db")a binding: env.DB from wrangler.jsonc
compile a query: db.query(sql)env.DB.prepare(sql)
bind params: .run({ $x: v }) / .run(v).bind(v) (or .bind({ $x: v }))
rows: .all(), .get().all(), .first() (both async)
write info: .run() → { changes, lastInsertRowid }.run() → meta.changes, meta.last_row_id
schema runs in-processwrangler d1 execute … --file=schema.sql

Easter egg: the --local D1 store is literally a SQLite file on disk. You can open it with Lesson 0006’s tools — sqlite3 .wrangler/state/v3/d1/**/*.sqlite "SELECT * FROM notes;" or a tiny bun:sqlite script. Your two tracks are the same engine.

Practice

Recall check (from memory, no peeking)

1. Which flag sends a D1 command to the hosted (production) database?

--remote targets the hosted DB; --local targets a SQLite file under .wrangler/state/. Same SQL, two targets.

2. What does env.DB.prepare(sql).bind(x).first() return when nothing matches?

null — the same contract as .get() in bun:sqlite. Perfect for “is this already stored?” checks.

3. How do you bind positional parameters in a D1 statement?

prepare("… VALUES (?, ?)").bind(a, b) — same ? placeholders as Lesson 0006, bound as data. Never build SQL strings from input.

Hands-on: your own hosted notes table

Provision your own database (so you own the lifecycle) and query it end-to-end.

  1. wrangler d1 create notes-db and copy the printed binding block into a wrangler.jsonc (name the worker anything). Or reuse d1-demo if you’re following the teacher’s copy.
  2. Write schema.sql with the notes table above; apply with wrangler d1 execute notes-db --local --file=schema.sql.
  3. Add a src/index.ts like Section 4 and run wrangler dev.
  4. Send two inserts with curl, then curl /notes to list them. Note the changes and the typed row shape in the JSON.
  5. Look at the result: wrangler d1 execute notes-db --local --command="SELECT * FROM notes;"
  6. Graduate to prod: wrangler d1 execute notes-db --remote --file=schema.sql, then read back with --remote --command="SELECT COUNT(*) FROM notes;". Your data now lives outside your laptop.

Check your work (immediate feedback):

wrangler d1 list                                    # your DB, with a UUID
wrangler d1 execute notes-db --remote --command="SELECT title FROM notes;"   # your curled rows

If the last command shows your inserted titles, you’ve run real SQL against a hosted database — schema, dev loop, and prod, all from a terminal. The whole thing took four commands, no server to install.

Read this next

Primary source for this lesson: Cloudflare D1: Get started, then the Worker API reference for the full D1Database/D1PreparedStatement/D1Result types (.raw(), meta, named-parameter binding). For production schema changes, graduate to wrangler d1 migrations create/apply — and next up in this track: driving D1 from a cron-scheduled Worker and from your own Bun scripts over HTTPS.