Storage · D1

Serverless SQL with D1

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.

SQLite, not Postgres. D1 uses SQLite's SQL dialect and storage engine, not PostgreSQL or MySQL. If you've only worked with those, expect some familiar syntax to behave differently — see the pitfall below before you assume feature parity.

How it works

A few architectural pieces matter for how you design around D1:

Worked example

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.

API note: the three query methods on a prepared statement are .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().

Pricing

D1 bills on rows read, rows written, and storage — not on compute time or open connections:

DimensionFreePaid (Workers Paid plan)
Rows read5 million / day25 billion / month included, then $0.001 / million rows
Rows written100,000 / day50 million / month included, then $1.00 / million rows
Storage5 GB total5 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.

Use cases

Pitfall: assuming Postgres/MySQL semantics, and ignoring row-read costs on unindexed queries. D1 speaks SQLite's SQL dialect: type affinity is looser than Postgres (SQLite mostly doesn't enforce column types strictly), there's no native 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.
Primary source

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.

A Worker runs SELECT * FROM orders WHERE customer_email = ? against a 2-million-row orders table with no index on customer_email. What's the main consequence?
Without scrolling up: what are the three query-execution methods on a D1 prepared statement, and what does each return?
Reveal

.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.

Anything above unclear — read replicas and session consistency, migrations vs. hand-run SQL, or when to split into per-tenant databases? Ask your AI teacher before moving on.
← Previous: Building an AI agent on Workers: tying Workers AI, Vectorize, and AI Gateway together Next: Schema design and migrations in D1 →