After this lesson you'll be able to create a D1 database, apply a schema with migrations, query it from a Worker with prepared statements, and explain when D1 is the right choice over KV.
D1 is Cloudflare's managed relational database: real SQL, built on SQLite, callable from a Worker with no connection pool, no server to provision, and no idle cost. If KV is a distributed key-value cache, D1 is the thing you reach for the moment your data has relationships — a user has many orders, an order has many line items, and you need to query and join across them with actual transactional guarantees. Under the hood, a D1 database is a SQLite file that Cloudflare manages, replicates, and serves through a HTTP/Worker-binding API rather than a raw file handle.
A few architectural pieces matter for how you design around D1:
withSession()) so a sequence of queries in one request can pin to a consistent view — useful right after a write, when you need to read your own write back.env.DB) and calls .prepare(sql).bind(params) to build a parameterized statement, then .run(), .first(), or .raw() to execute it and shape the result.Create the database with Wrangler:
npx wrangler d1 create app-db
This prints a database_id. Add the binding to wrangler.toml:
[[d1_databases]]
binding = "DB"
database_name = "app-db"
database_id = "<the-id-wrangler-printed>"
Define the schema as a migration file rather than hand-running SQL against production:
npx wrangler d1 migrations create app-db create_users_and_posts
This scaffolds a numbered file under migrations/. Fill it in:
-- migrations/0001_create_users_and_posts.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_posts_user_id ON posts(user_id);
Apply it locally, then to production, once you're satisfied:
npx wrangler d1 migrations apply app-db --local
npx wrangler d1 migrations apply app-db --remote
Query it from a Worker using prepared statements — never string-concatenate user input into SQL:
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const userId = url.searchParams.get("userId");
// Prepared statement with a bound parameter — safe against SQL injection.
const { results } = await env.DB.prepare(
`SELECT posts.id, posts.title, posts.created_at
FROM posts
JOIN users ON users.id = posts.user_id
WHERE users.id = ?
ORDER BY posts.created_at DESC`
)
.bind(userId)
.run();
return Response.json(results);
},
} satisfies ExportedHandler<Env>;
Writes work the same way — .run() on an INSERT/UPDATE gives you a meta object with changes and last_row_id. For multiple statements that must succeed or fail together, use env.DB.batch([...]), which runs them sequentially as a single transaction.
.run() (full result object with metadata — the one to use for both reads and writes), .first() (just the first row, or a single column if you pass its name), and .raw() (rows as arrays instead of objects). There is no separate .all() method in the current D1 client API — if you see that name in older material, read it as .run().
D1 bills on rows read, rows written, and storage — not on compute time or open connections:
| Dimension | Free | Paid (Workers Paid plan) |
|---|---|---|
| Rows read | 5 million / day | 25 billion / month included, then $0.001 / million rows |
| Rows written | 100,000 / day | 50 million / month included, then $1.00 / million rows |
| Storage | 5 GB total | 5 GB included, then $0.75 / GB-month |
There's no charge for data transfer out of D1, and no charge for having many databases sitting idle — D1 only bills for rows actually touched and bytes actually stored. Per Cloudflare's current limits, a single database maxes out at 10 GB on the Paid plan (500 MB on Free), and an account can hold up to 50,000 databases on Paid (10 on Free) — a useful pattern for per-tenant isolation. Confirm current figures on the pricing page linked below before quoting them, since pricing is subject to change.
JOIN to answer a real question.batch()), not a pattern you can safely build on KV's independent key writes.tenant_id column.ENUM or array type, and some functions and syntax you know from Postgres/MySQL simply don't exist or behave differently. Test your actual queries against D1/SQLite rather than assuming compatibility. Separately, because you're billed per row read, a query without a supporting index doesn't just run slow — a full table scan reads every row in the table to satisfy the filter, and every one of those rows counts against your rows-read quota and bill. Add indexes on columns you filter or join on (like idx_posts_user_id above) before this becomes a cost surprise, not just a performance one.
The D1 overview is the canonical entry point for architecture; pair it with the D1 pricing page and limits page for current numbers, since both are subject to change.
SELECT * FROM orders WHERE customer_email = ? against a 2-million-row orders table with no index on customer_email. What's the main consequence?.run() returns the full result object (rows plus metadata like changes/last_row_id) — the default choice for both reads and writes. .first() returns just the first row (or a single named column). .raw() returns rows as arrays instead of objects. There is no .all() method in the current API.