| name | cf-d1 |
| description | Generate D1 SQLite database schemas, queries, and edge-native patterns. Use when the user wants to create, query, or manage Cloudflare D1 databases with Workers or Pages. |
| argument-hint | [operation] |
| disable-model-invocation | true |
| allowed-tools | Read, Write, Edit, Glob, Grep, Bash(npx wrangler *), Bash(wrangler *), Bash(npm *) |
| user-invocable | true |
Instructions
You are a Cloudflare D1 expert. Generate production-ready D1 database schemas, queries, migrations, and integration patterns.
Step 1: Gather requirements
Determine from user input or $ARGUMENTS:
- Operation: schema design, query writing, migration creation, seed data
- Data model: tables, relationships, constraints, indexes
- Access pattern: read-heavy, write-heavy, mixed
- Integration: raw D1 API, Drizzle ORM, Prisma with D1 adapter
- Scale: read replication needs, location hints
Step 2: Create database and configure binding
npx wrangler d1 create my-app-db
npx wrangler d1 list
npx wrangler d1 info my-app-db
wrangler.toml binding:
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-12-01"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxx-xxxx-xxxx-xxxx"
[[d1_databases]]
binding = "ANALYTICS_DB"
database_name = "analytics-db"
database_id = "yyyy-yyyy-yyyy-yyyy"
Step 3: Generate schema with migrations
Create migration files:
npx wrangler d1 migrations create my-app-db init-schema
Schema design (migrations/0001_init-schema.sql):
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
password_hash TEXT NOT NULL,
avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin', 'moderator')),
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role) WHERE is_active = 1;
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
content TEXT NOT NULL,
excerpt TEXT,
author_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'published', 'archived')),
published_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_status ON posts(status, published_at DESC);
CREATE INDEX idx_posts_slug ON posts(slug);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES comments(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_comments_post ON comments(post_id, created_at DESC);
CREATE INDEX idx_comments_user ON comments(user_id);
CREATE TRIGGER update_users_timestamp
AFTER UPDATE ON users
FOR EACH ROW
BEGIN
UPDATE users SET updated_at = datetime('now') WHERE id = OLD.id;
END;
CREATE TRIGGER update_posts_timestamp
AFTER UPDATE ON posts
FOR EACH ROW
BEGIN
UPDATE posts SET updated_at = datetime('now') WHERE id = OLD.id;
END;
Apply migrations:
npx wrangler d1 migrations apply my-app-db --local
npx wrangler d1 migrations apply my-app-db --remote
npx wrangler d1 migrations list my-app-db
Step 4: Generate D1 client API queries
interface Env {
DB: D1Database;
}
async function createUser(db: D1Database, user: { email: string; name: string; passwordHash: string }) {
const result = await db
.prepare("INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?) RETURNING *")
.bind(user.email, user.name, user.passwordHash)
.first();
return result;
}
async function getUserById(db: D1Database, id: string) {
return db.prepare("SELECT id, email, name, role, created_at FROM users WHERE id = ?").bind(id).first();
}
async function getPublishedPosts(db: D1Database, limit = 20, offset = 0) {
const { results } = await db
.prepare(`
SELECT p.id, p.title, p.slug, p.excerpt, p.published_at,
u.name AS author_name, u.avatar_url AS author_avatar
FROM posts p
JOIN users u ON p.author_id = u.id
WHERE p.status = 'published'
ORDER BY p.published_at DESC
LIMIT ? OFFSET ?
`)
.bind(limit, offset)
.all();
return results;
}
async function updatePost(db: D1Database, id: number, data: { title?: string; content?: string; status?: string }) {
const fields: string[] = [];
const values: unknown[] = [];
if (data.title) { fields.push("title = ?"); values.push(data.title); }
if (data.content) { fields.push("content = ?"); values.push(data.content); }
if (data.status) {
fields.push("status = ?");
values.push(data.status);
if (data.status === "published") {
fields.push("published_at = datetime('now')");
}
}
values.push(id);
return db
.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ? RETURNING *`)
.bind(...values)
.first();
}
async function deletePost(db: D1Database, id: number) {
return db.prepare("DELETE FROM posts WHERE id = ?").bind(id).run();
}
Step 5: Batch and transaction operations
async function createPostWithTags(
db: D1Database,
post: { title: string; slug: string; content: string; authorId: string },
tagIds: number[]
) {
const statements: D1PreparedStatement[] = [
db.prepare(
"INSERT INTO posts (title, slug, content, author_id, status) VALUES (?, ?, ?, ?, 'draft') RETURNING id"
).bind(post.title, post.slug, post.content, post.authorId),
];
const results = await db.batch(statements);
const postId = (results[0].results?.[0] as { id: number })?.id;
if (postId && tagIds.length > 0) {
const tagStatements = tagIds.map((tagId) =>
db.prepare("INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)").bind(postId, tagId)
);
await db.batch(tagStatements);
}
return postId;
}
async function seedDatabase(db: D1Database) {
await db.exec(`
INSERT INTO tags (name, slug) VALUES ('JavaScript', 'javascript');
INSERT INTO tags (name, slug) VALUES ('TypeScript', 'typescript');
INSERT INTO tags (name, slug) VALUES ('Cloudflare', 'cloudflare');
INSERT INTO tags (name, slug) VALUES ('Workers', 'workers');
`);
}
Step 6: Full-text search with FTS5
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
title,
content,
content='posts',
content_rowid='id',
tokenize='porter unicode61'
);
INSERT INTO posts_fts(rowid, title, content)
SELECT id, title, content FROM posts;
CREATE TRIGGER posts_fts_insert AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid, title, content) VALUES (NEW.id, NEW.title, NEW.content);
END;
CREATE TRIGGER posts_fts_delete AFTER DELETE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, rowid, title, content)
VALUES('delete', OLD.id, OLD.title, OLD.content);
END;
CREATE TRIGGER posts_fts_update AFTER UPDATE ON posts BEGIN
INSERT INTO posts_fts(posts_fts, rowid, title, content)
VALUES('delete', OLD.id, OLD.title, OLD.content);
INSERT INTO posts_fts(rowid, title, content)
VALUES (NEW.id, NEW.title, NEW.content);
END;
async function searchPosts(db: D1Database, query: string, limit = 20) {
const { results } = await db
.prepare(`
SELECT p.id, p.title, p.slug, p.excerpt,
highlight(posts_fts, 0, '<mark>', '</mark>') AS title_highlighted,
snippet(posts_fts, 1, '<mark>', '</mark>', '...', 32) AS content_snippet,
rank
FROM posts_fts
JOIN posts p ON posts_fts.rowid = p.id
WHERE posts_fts MATCH ?
ORDER BY rank
LIMIT ?
`)
.bind(query, limit)
.all();
return results;
}
Step 7: JSON support
async function createSetting(db: D1Database, key: string, value: object) {
await db
.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES (?, json(?))")
.bind(key, JSON.stringify(value))
.run();
}
async function getSettingField(db: D1Database, key: string, field: string) {
return db
.prepare("SELECT json_extract(value, ?) AS field_value FROM settings WHERE key = ?")
.bind(`$.${field}`, key)
.first();
}
async function getUsersByPreference(db: D1Database, preference: string) {
const { results } = await db
.prepare(`
SELECT u.id, u.name
FROM users u, json_each(u.preferences, '$.tags') AS t
WHERE t.value = ?
`)
.bind(preference)
.all();
return results;
}
Step 8: Drizzle ORM integration
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
});
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
export const users = sqliteTable("users", {
id: text("id").primaryKey().$defaultFn(() => crypto.randomUUID()),
email: text("email").notNull().unique(),
name: text("name").notNull(),
passwordHash: text("password_hash").notNull(),
role: text("role", { enum: ["user", "admin", "moderator"] }).notNull().default("user"),
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
});
export const posts = sqliteTable("posts", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
slug: text("slug").notNull().unique(),
content: text("content").notNull(),
authorId: text("author_id").notNull().references(() => users.id, { onDelete: "cascade" }),
status: text("status", { enum: ["draft", "published", "archived"] }).notNull().default("draft"),
publishedAt: text("published_at"),
createdAt: text("created_at").notNull().default(sql`(datetime('now'))`),
updatedAt: text("updated_at").notNull().default(sql`(datetime('now'))`),
});
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function createDb(d1: D1Database) {
return drizzle(d1, { schema });
}
import { eq, desc } from "drizzle-orm";
import { createDb } from "./db";
import { users, posts } from "./db/schema";
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const db = createDb(env.DB);
const publishedPosts = await db
.select({
id: posts.id,
title: posts.title,
authorName: users.name,
})
.from(posts)
.innerJoin(users, eq(posts.authorId, users.id))
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt))
.limit(20);
return Response.json(publishedPosts);
},
};
Step 9: Query performance optimization
async function analyzeQuery(db: D1Database, sql: string) {
const { results } = await db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all();
return results;
}
async function getPostsCursor(db: D1Database, cursor?: string, limit = 20) {
let query = `
SELECT id, title, slug, published_at
FROM posts
WHERE status = 'published'
`;
const params: unknown[] = [];
if (cursor) {
query += " AND published_at < ?";
params.push(cursor);
}
query += " ORDER BY published_at DESC LIMIT ?";
params.push(limit + 1);
const stmt = db.prepare(query);
const { results } = await stmt.bind(...params).all();
const hasMore = results.length > limit;
const items = results.slice(0, limit);
const nextCursor = hasMore ? (items[items.length - 1] as { published_at: string }).published_at : null;
return { items, nextCursor, hasMore };
}
Step 10: Local development and backups
npx wrangler d1 execute my-app-db --local --command "SELECT * FROM users LIMIT 5"
npx wrangler d1 execute my-app-db --remote --command "SELECT COUNT(*) FROM users"
npx wrangler d1 execute my-app-db --local --file ./seed.sql
npx wrangler d1 export my-app-db --remote --output ./backup.sql
Best practices
- Use parameterized queries (
.bind()) to prevent SQL injection -- never concatenate user input
- Create indexes for columns used in WHERE, JOIN, and ORDER BY clauses
- Use
batch() for multiple related operations to reduce round-trips and ensure atomicity
- Use cursor-based pagination instead of OFFSET for large datasets
- Use
first() instead of all() when expecting a single row
- Store dates as TEXT in ISO 8601 format using
datetime('now') for SQLite compatibility
- Use FTS5 for text search rather than
LIKE '%term%' (which cannot use indexes)
- Set location hints for databases that are primarily accessed from specific regions
- Use Drizzle ORM for type-safe queries in larger applications
- Keep migrations small and incremental (one concern per migration file)
Anti-patterns to avoid
- Do NOT use
exec() for user-provided input (no parameter binding, SQL injection risk)
- Do NOT create too many indexes (each index increases write latency and storage)
- Do NOT use
SELECT * in production queries (specify needed columns explicitly)
- Do NOT use D1 for high-write-throughput workloads (SQLite is single-writer)
- Do NOT store large binary blobs in D1 (use R2 and store the key/URL in D1)
- Do NOT skip migrations and modify the schema manually
- Do NOT use
OFFSET for deep pagination (performance degrades linearly with offset value)
- Do NOT ignore
meta.changes and meta.duration in query results (useful for monitoring)
Cost optimization
- Free tier: 5 million rows read/day, 100,000 rows written/day, 5GB storage
- Paid plan: $0.001 per million rows read, $1.00 per million rows written
- Optimize queries to read fewer rows (use indexes, limit result sets)
- Use
batch() to combine multiple reads into a single call (counts as one billable operation)
- Cache frequently-read data in KV to reduce D1 read operations
- Use read replicas for read-heavy workloads to distribute load
- Monitor row reads/writes in the Cloudflare Dashboard under D1 Analytics
- Avoid full table scans by ensuring queries hit indexes (use EXPLAIN QUERY PLAN)