Build type-safe D1 databases with Drizzle ORM for Cloudflare Workers. Includes schema definition, migrations with Drizzle Kit, relations, and D1 batch API patterns...
Status: Production Ready ✅ Last Updated: 2025-10-24 Latest Version: drizzle-orm@0.44.7, drizzle-kit@0.31.5 Dependencies: cloudflare-d1, cloudflare-worker-base
npm install drizzle-orm
npm install -D drizzle-kit
# Or with pnpm
pnpm add drizzle-orm
pnpm add -D drizzle-kit
Why Drizzle?
Create drizzle.config.ts in your project root:
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
schema: './src/db/schema.ts',
out: './migrations',
dialect: 'sqlite',
driver: 'd1-http',
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
});
CRITICAL:
dialect: 'sqlite' - D1 is SQLite-baseddriver: 'd1-http' - For remote database access via HTTP APIUpdate wrangler.jsonc:
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database",
"database_id": "your-database-id",
"preview_database_id": "local-db",
"migrations_dir": "./migrations" // ← Points to Drizzle migrations!
}
]
}
Why this matters:
migrations_dir tells Wrangler where to find SQL migration files./migrations (from drizzle.config.ts out)wrangler d1 migrations applyCreate src/db/schema.ts:
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
import { relations } from 'drizzle-orm';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
email: text('email').notNull().unique(),
name: text('name').notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
title: text('title').notNull(),
content: text('content').notNull(),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
});
// Define relations for type-safe joins
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
export const postsRelations = relations(posts, ({ one }) => ({
author: one(users, { fields: [posts.authorId], references: [users.id] }),
}));
Key Points:
integer for auto-incrementing IDsinteger with mode: 'timestamp' for dates (D1 doesn't have native date type).$defaultFn() for dynamic defaults (not .default() for functions)# Step 1: Generate SQL migration from schema
npx drizzle-kit generate
# Step 2: Apply to local database (for testing)
npx wrangler d1 migrations apply my-database --local
# Step 3: Apply to production database
npx wrangler d1 migrations apply my-database --remote
Why this workflow:
drizzle-kit generate creates versioned SQL files in ./migrations--local flagCreate src/index.ts:
import { drizzle } from 'drizzle-orm/d1';
import { users, posts } from './db/schema';
import { eq } from 'drizzle-orm';
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const db = drizzle(env.DB);
// Type-safe select with full inference
const allUsers = await db.select().from(users);
// Select with where clause
const user = await db
.select()
.from(users)
.where(eq(users.email, 'test@example.com'))
.get(); // .get() returns first result or undefined
// Insert with returning
const [newUser] = await db
.insert(users)
.values({ email: 'new@example.com', name: 'New User' })
.returning();
// Update
await db
.update(users)
.set({ name: 'Updated Name' })
.where(eq(users.id, 1));
// Delete
await db
.delete(users)
.where(eq(users.id, 1));
return Response.json({ allUsers, user, newUser });
},
};
CRITICAL:
.get() for single results (returns first or undefined).all() for all results (returns array)drizzle-orm: eq, gt, lt, and, or, etc..returning() works with D1 (returns inserted/updated rows)# Core dependencies
npm install drizzle-orm
# Dev dependencies
npm install -D drizzle-kit @cloudflare/workers-types
# Optional: For local development with SQLite
npm install -D better-sqlite3
Create .env (never commit this!):
# Get these from Cloudflare dashboard
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_DATABASE_ID=your-database-id
CLOUDFLARE_D1_TOKEN=your-api-token
How to get these:
wrangler d1 create my-database (output includes ID)my-project/
├── drizzle.config.ts # Drizzle Kit configuration
├── wrangler.jsonc # Wrangler configuration
├── src/
│ ├── index.ts # Worker entry point
│ └── db/
│ └── schema.ts # Database schema
├── migrations/ # Generated by drizzle-kit
│ ├── meta/
│ │ └── _journal.json
│ └── 0001_initial_schema.sql
└── package.json
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true
}
}
✅ Use drizzle-kit generate for migrations - Never write SQL manually
✅ Test migrations locally first - Always use --local flag before --remote
✅ Define relations in schema - For type-safe joins and nested queries
✅ Use .get() for single results - Returns first row or undefined
✅ Use db.batch() for transactions - D1 doesn't support SQL BEGIN/COMMIT
✅ Use integer with mode: 'timestamp' for dates - D1 doesn't have native date type
✅ Use .$defaultFn() for dynamic defaults - Not .default() for functions
✅ Set migrations_dir in wrangler.jsonc - Points to ./migrations
✅ Use environment variables for credentials - Never commit API keys
✅ Import operators from drizzle-orm - eq, gt, and, or, etc.
❌ Never use SQL BEGIN TRANSACTION - D1 requires batch API (see Known Issue #1)
❌ Never mix wrangler d1 migrations apply and drizzle-kit migrate - Use Wrangler only
❌ Never use drizzle-kit push for production - Use generate + apply workflow
❌ Never forget to apply migrations locally first - Always test with --local
❌ Never commit drizzle.config.ts with hardcoded credentials - Use env vars
❌ Never use .default() for function calls - Use .$defaultFn() instead
❌ Never rely on prepared statement caching - D1 doesn't cache like SQLite (see Known Issue #7)
❌ Never use traditional transaction rollback - Use error handling in batch (see Known Issue #8)
❌ Never mix wrangler.toml and wrangler.jsonc - Use wrangler.jsonc consistently (see Known Issue #12)
This skill prevents 12 documented issues:
Error: D1_ERROR: Cannot use BEGIN TRANSACTION
Source: https://github.com/drizzle-team/drizzle-orm/issues/4212
Why It Happens:
Drizzle tries to use SQL BEGIN TRANSACTION statements, but Cloudflare D1 raises a D1_ERROR requiring use of state.storage.transaction() APIs instead. Users cannot work around this error as Drizzle attempts to use BEGIN TRANSACTION when using bindings in Workers.
Prevention: Use D1's batch API instead of Drizzle's transaction API:
// ❌ DON'T: Use traditional transactions
await db.transaction(async (tx) => {
await tx.insert(users).values({ email: 'test@example.com', name: 'Test' });
await tx.insert(posts).values({ title: 'Post', content: 'Content', authorId: 1 });
});
// ✅ DO: Use D1 batch API
await db.batch([
db.insert(users).values({ email: 'test@example.com', name: 'Test' }),
db.insert(posts).values({ title: 'Post', content: 'Content', authorId: 1 }),
]);
Template: See templates/transactions.ts
Error: FOREIGN KEY constraint failed: SQLITE_CONSTRAINT
Source: https://github.com/drizzle-team/drizzle-orm/issues/4089
Why It Happens:
When generating migrations for Cloudflare D1, Drizzle-ORM uses the statement PRAGMA foreign_keys = OFF; which causes migrations to fail when executed. If tables have data and new migrations are generated, they fail with foreign key errors.
Prevention:
export const posts = sqliteTable('posts', {
id: integer('id').primaryKey({ autoIncrement: true }),
authorId: integer('author_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }), // ← Cascading deletes
});
Template: See templates/schema.ts
Error: Error: No such module "wrangler"
Source: https://github.com/drizzle-team/drizzle-orm/issues/4257
Why It Happens: When using OpenNext, Drizzle, and D1, users encounter "Error: No such module 'wrangler'" which works locally but fails when deployed to Cloudflare Workers. This affects Next.js projects deployed to Cloudflare.
Prevention:
wrangler package in runtime codeimport { drizzle } from 'drizzle-orm/d1'Template: See templates/cloudflare-worker-integration.ts
Error: TypeError: Cannot read property 'prepare' of undefined or env.DB is undefined
Why It Happens:
Missing or incorrect wrangler.jsonc configuration. The binding name in code doesn't match the binding name in config.
Prevention: Ensure binding names match exactly:
// wrangler.jsonc
{
"d1_databases": [
{
"binding": "DB", // ← Must match env.DB in code
"database_name": "my-database",
"database_id": "your-db-id"
}
]
}
// src/index.ts
export interface Env {
DB: D1Database; // ← Must match binding name
}
export default {
async fetch(request: Request, env: Env) {
const db = drizzle(env.DB); // ← Accessing the binding
// ...
},
};
Reference: See references/wrangler-setup.md
Error: Migration failed to apply: near "...": syntax error
Why It Happens: Syntax errors in generated SQL, conflicting migrations, or applying migrations out of order.
Prevention:
npx wrangler d1 migrations apply my-database --local
Review generated SQL in ./migrations before applying
If migration fails, delete it and regenerate:
rm -rf migrations/
npx drizzle-kit generate
Reference: See references/migration-workflow.md
Error: Type instantiation is excessively deep and possibly infinite
Why It Happens: Complex circular references in relations cause TypeScript to fail type inference.
Prevention: Use explicit type annotations in relations:
import { InferSelectModel } from 'drizzle-orm';
// Define types explicitly
export type User = InferSelectModel<typeof users>;
export type Post = InferSelectModel<typeof posts>;
// Use explicit types in relations
export const usersRelations = relations(users, ({ many }) => ({
posts: many(posts),
}));
Reference: See references/schema-patterns.md
Error: Stale or incorrect results from queries
Why It Happens: Developers expect D1 to cache prepared statements like traditional SQLite, but D1 doesn't maintain statement caches between requests.
Prevention:
Always use .all(), .get(), or .run() methods correctly:
// ✅ Correct: Use .all() for arrays
const users = await db.select().from(users).all();
// ✅ Correct: Use .get() for single result
const user = await db.select().from(users).where(eq(users.id, 1)).get();
// ❌ Wrong: Don't rely on caching behavior
const stmt = db.select().from(users); // Don't reuse across requests
Template: See templates/prepared-statements.ts
Error: Transaction doesn't roll back on error
Why It Happens: D1 batch API doesn't support traditional transaction rollback. If one statement in a batch fails, others may still succeed.
Prevention: Implement error handling with manual cleanup:
try {
const results = await db.batch([
db.insert(users).values({ email: 'test@example.com', name: 'Test' }),
db.insert(posts).values({ title: 'Post', content: 'Content', authorId: 1 }),
]);
// Both succeeded
} catch (error) {
// Manual cleanup if needed
console.error('Batch failed:', error);
// Potentially delete partially created records
}
Template: See templates/transactions.ts
Error: Type errors with strict: true in tsconfig.json
Why It Happens: Drizzle types can be loose, and TypeScript strict mode catches potential issues.
Prevention: Use explicit return types and assertions:
// ✅ Explicit return type
async function getUser(id: number): Promise<User | undefined> {
return await db.select().from(users).where(eq(users.id, id)).get();
}
// ✅ Type assertion when needed
const user = await db.select().from(users).where(eq(users.id, 1)).get() as User;
Error: Cannot find drizzle.config.ts
Why It Happens:
Wrong file location or incorrect file name. Drizzle Kit looks for drizzle.config.ts in the project root.
Prevention:
drizzle.config.ts (not drizzle.config.js or drizzle-config.ts)src/ or subdirectory)--config flag:npx drizzle-kit generate --config=custom.config.ts
Error: Changes not appearing in local development or production
Why It Happens:
Applying migrations to the wrong database. Forgetting to use --local flag during development or using it in production.
Prevention: Use consistent flags:
# Development: Always use --local
npx wrangler d1 migrations apply my-database --local
npx wrangler dev # Uses local database
# Production: Use --remote
npx wrangler d1 migrations apply my-database --remote
npx wrangler deploy # Uses remote database
Reference: See references/migration-workflow.md
Error: Configuration not recognized or comments causing errors
Why It Happens: Mixing TOML and JSON config formats. TOML doesn't support comments the same way, and JSON doesn't support TOML syntax.
Prevention:
Use wrangler.jsonc consistently:
// wrangler.jsonc (supports comments!)
{
"name": "my-worker",
// This is a comment
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database"
}
]
}
Not:
# wrangler.toml (old format)
name = "my-worker"
Reference: See references/wrangler-setup.md
import { defineConfig } from 'drizzle-kit';
export default defineConfig({
// Schema location (can be file or directory)
schema: './src/db/schema.ts',
// Output directory for migrations
out: './migrations',
// Database dialect
dialect: 'sqlite',
// D1 HTTP driver (for remote access)
driver: 'd1-http',
// Cloudflare credentials
dbCredentials: {
accountId: process.env.CLOUDFLARE_ACCOUNT_ID!,
databaseId: process.env.CLOUDFLARE_DATABASE_ID!,
token: process.env.CLOUDFLARE_D1_TOKEN!,
},
// Verbose output
verbose: true,
// Strict mode
strict: true,
});
{
"name": "my-worker",
"main": "src/index.ts",
"compatibility_date": "2025-10-11",
// D1 database bindings
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database",
"database_id": "your-production-db-id",
"preview_database_id": "local-db",
"migrations_dir": "./migrations" // Points to Drizzle migrations
}
],
// Node.js compatibility for Drizzle
"compatibility_flags": ["nodejs_compat"]
}
{
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:migrate:local": "wrangler d1 migrations apply my-database --local",
"db:migrate:remote": "wrangler d1 migrations apply my-database --remote"
}
}
import { drizzle } from 'drizzle-orm/d1';
import { users } from './db/schema';
import { eq, and, or, gt, lt, like } from 'drizzle-orm';
const db = drizzle(env.DB);
// Create
const [newUser] = await db
.insert(users)
.values({ email: 'new@example.com', name: 'New User' })
.returning();
// Read (all)
const allUsers = await db.select().from(users).all();
// Read (single)
const user = await db
.select()
.from(users)
.where(eq(users.id, 1))
.get();
// Read (with conditions)
const activeUsers = await db
.select()
.from(users)
.where(and(
gt(users.createdAt, new Date('2024-01-01')),
like(users.email, '%@example.com')
))
.all();
// Update
await db
.update(users)
.set({ name: 'Updated Name' })
.where(eq(users.id, 1));
// Delete
await db
.delete(users)
.where(eq(users.id, 1));
Template: See templates/basic-queries.ts
import { drizzle } from 'drizzle-orm/d1';
import { users, posts } from './db/schema';
import { eq } from 'drizzle-orm';
const db = drizzle(env.DB, { schema: { users, posts, usersRelations, postsRelations } });
// Nested query (requires relations defined)
const usersWithPosts = await db.query.users.findMany({
with: {
posts: true,
},
});
// Manual join
const usersWithPosts2 = await db
.select({
user: users,
post: posts,
})
.from(users)
.leftJoin(posts, eq(posts.authorId, users.id))
.all();
// Filter nested queries
const userWithRecentPosts = await db.query.users.findFirst({
where: eq(users.id, 1),
with: {
posts: {
where: gt(posts.createdAt, new Date('2024-01-01')),
orderBy: [desc(posts.createdAt)],
limit: 10,
},
},
});
Template: See templates/relations-queries.ts
import { drizzle } from 'drizzle-orm/d1';
import { users, posts } from './db/schema';
const db = drizzle(env.DB);
// Batch insert
const results = await db.batch([
db.insert(users).values({ email: 'user1@example.com', name: 'User 1' }),
db.insert(users).values({ email: 'user2@example.com', name: 'User 2' }),
db.insert(users).values({ email: 'user3@example.com', name: 'User 3' }),
]);
// Batch with error handling
try {
const results = await db.batch([
db.insert(users).values({ email: 'test@example.com', name: 'Test' }),
db.insert(posts).values({ title: 'Post', content: 'Content', authorId: 1 }),
]);
console.log('All operations succeeded');
} catch (error) {
console.error('Batch failed:', error);
// Manual cleanup if needed
}
Template: See templates/transactions.ts
import { drizzle } from 'drizzle-orm/d1';
import { users } from './db/schema';
import { eq } from 'drizzle-orm';
const db = drizzle(env.DB);
// Prepared statement (reusable query)
const getUserById = db
.select()
.from(users)
.where(eq(users.id, sql.placeholder('id')))
.prepare();
// Execute with different parameters
const user1 = await getUserById.get({ id: 1 });
const user2 = await getUserById.get({ id: 2 });
Note: D1 doesn't cache prepared statements between requests like traditional SQLite.
Template: See templates/prepared-statements.ts
check-versions.sh - Verify package versions are up to date
./scripts/check-versions.sh
Output:
Checking Drizzle ORM versions...
✓ drizzle-orm: 0.44.7 (latest)
✓ drizzle-kit: 0.31.5 (latest)
Claude should load these when you need specific deep-dive information:
When to load:
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
import { users } from './db/schema';
// Infer types from schema
export type User = InferSelectModel<typeof users>;
export type NewUser = InferInsertModel<typeof users>;
// Usage
const user: User = await db.select().from(users).where(eq(users.id, 1)).get();
const newUser: NewUser = {
email: 'test@example.com',
name: 'Test User',
// createdAt is optional (has default)
};
Development:
src/db/schema.tsnpm run db:generate./migrationsnpm run db:migrate:localnpm run devProduction:
npm run deploynpm run db:migrate:remoteReference: See references/migration-workflow.md
D1/SQLite doesn't have native date type. Use integer with timestamp mode:
export const events = sqliteTable('events', {
id: integer('id').primaryKey({ autoIncrement: true }),
// ✅ Use integer with timestamp mode
createdAt: integer('created_at', { mode: 'timestamp' }).$defaultFn(() => new Date()),
// ❌ Don't use text for dates
// createdAt: text('created_at'),
});
// Query with date comparisons
const recentEvents = await db
.select()
.from(events)
.where(gt(events.createdAt, new Date('2024-01-01')))
.all();
Required:
drizzle-orm@0.44.7 - ORM runtimedrizzle-kit@0.31.5 - CLI tool for migrationsOptional:
better-sqlite3@12.4.1 - For local SQLite development@cloudflare/workers-types@4.20251014.0 - TypeScript typesSkills:
/drizzle-team/drizzle-orm-docs{
"dependencies": {
"drizzle-orm": "^0.44.7"
},
"devDependencies": {
"drizzle-kit": "^0.31.5",
"@cloudflare/workers-types": "^4.20251014.0",
"better-sqlite3": "^12.4.1"
}
}
This skill is based on production patterns from:
D1_ERROR: Cannot use BEGIN TRANSACTIONSolution: Use db.batch() instead of db.transaction() (see Known Issue #1)
Solution: Define cascading deletes and ensure proper migration order (see Known Issue #2)
Solution: Test locally first with --local flag, review generated SQL (see Known Issue #5)
Solution: Use explicit type annotations with InferSelectModel (see Known Issue #6)
Solution: Check wrangler.jsonc binding names match code (see Known Issue #4)
drizzle-kit generatewrangler d1 migrations apply --local--remotewrangler deployQuestions? Issues?
references/common-errors.md for all 12 known issuesToken Savings: ~60% compared to manual setup Error Prevention: 100% (all 12 known issues documented and prevented) Ready for production! ✅