Fix: Cloudflare D1 Not Working — Binding Errors, Local vs Remote, Migrations, and Foreign Keys
Quick Answer
How to fix Cloudflare D1 errors — D1_ERROR no such table, binding undefined, --local vs --remote drift, migrations not applied, prepared statement bind index, foreign keys not enforced, and concurrent writes.
The Error
You query D1 from a Worker and get this:
D1_ERROR: no such table: usersOr the binding is missing entirely:
TypeError: Cannot read properties of undefined (reading 'prepare')
at fetch (worker.ts:5)Or your local dev works, production fails:
$ wrangler dev # Works.
$ wrangler deploy # Deployed, but queries fail in prod.Or a foreign key constraint just… isn’t enforced:
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER REFERENCES users(id)
);
-- This shouldn't work but does:
INSERT INTO posts (id, user_id) VALUES (1, 99999); -- No such user!Why This Happens
D1 is SQLite running on Cloudflare’s edge. It’s fast and simple, but the Worker-binding model and the --local / --remote distinction cause most onboarding pain. Four layers are involved in any query:
- Binding — your Worker accesses D1 via an environment binding (
env.DB). Ifwrangler.tomldoesn’t declare it, or the database ID doesn’t match,env.DBisundefinedat runtime. - Local vs remote.
wrangler devdefaults to a local SQLite file under.wrangler/state/.wrangler dev --remoteand production both use the remote D1 database. Migrations and schema changes don’t auto-sync between them. - Migrations applied or not. D1 has a migration system (
wrangler d1 migrations apply), but it’s opt-in. You can also run ad-hocwrangler d1 execute --file=.... Mixing the two leads to “ran locally, forgot to run remotely” mistakes. - SQLite quirks. Foreign keys are off by default. Concurrent writes have specific semantics. Quoting in CLI is shell-dependent. These bite even after the binding works.
Fix 1: Declare the Binding in wrangler.toml
Every D1 database your Worker uses needs an entry in wrangler.toml:
name = "my-worker"
main = "src/worker.ts"
compatibility_date = "2026-05-01"
[[d1_databases]]
binding = "DB" # env.DB in your code
database_name = "my-app-prod"
database_id = "12345678-abcd-..." # From `wrangler d1 list`The binding value is what shows up on env. If you set binding = "DB", you access it as env.DB. If you set binding = "USERS_DB", it’s env.USERS_DB. The names don’t have to match the database name.
Get the database ID by listing your databases:
wrangler d1 listGenerate fresh types so TypeScript knows about the binding:
wrangler typesThis writes worker-configuration.d.ts (or .dev.vars.d.ts in older Wrangler) declaring the Env interface. Import the type in your handler:
export interface Env {
DB: D1Database;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const { results } = await env.DB.prepare("SELECT * FROM users").all();
return Response.json(results);
},
};Fix 2: --local vs --remote — Know Which One You’re Hitting
By default, almost every D1 CLI command runs against the local SQLite file:
wrangler dev # Local DB
wrangler d1 execute my-app-prod --command="SELECT * FROM users" # Local DB
wrangler d1 migrations apply my-app-prod # Local DBAdd --remote to hit production:
wrangler d1 execute my-app-prod --remote --command="SELECT * FROM users"
wrangler d1 migrations apply my-app-prod --remoteTo test the Worker against the remote DB:
wrangler dev --remotePro Tip: Alias the verbose commands. A package.json like this saves the daily mistakes:
{
"scripts": {
"db:local": "wrangler d1 execute my-app-prod",
"db:prod": "wrangler d1 execute my-app-prod --remote",
"migrate:local": "wrangler d1 migrations apply my-app-prod",
"migrate:prod": "wrangler d1 migrations apply my-app-prod --remote"
}
}Then npm run db:prod -- --command="..." is harder to confuse than the raw command.
Fix 3: Migrations — Create, Apply Locally, Then Remote
Create a migration file:
wrangler d1 migrations create my-app-prod add_users_table
# Creates migrations/0001_add_users_table.sqlEdit the file:
-- migrations/0001_add_users_table.sql
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);Apply locally first, run your tests, then apply to remote:
wrangler d1 migrations apply my-app-prod # Local
npm test
wrangler d1 migrations apply my-app-prod --remote # ProductionD1 tracks applied migrations in a d1_migrations table — re-running apply is safe; already-applied migrations are skipped.
Common Mistake: Editing an already-applied migration file. D1 doesn’t re-run it. Either create a new migration, or (in dev only) reset with rm -rf .wrangler/state/v3/d1 and re-apply from scratch.
Fix 4: Reset Your Local DB Without Touching Remote
When local schema drifts, blow it away:
rm -rf .wrangler/state/v3/d1
wrangler d1 migrations apply my-app-prodThis is the local-only equivalent of dropping and recreating the database. Your remote production data is untouched — the local file lives in .wrangler/state/, not on Cloudflare’s servers.
Fix 5: Use Prepared Statements With .bind()
D1 supports parameterized queries. Use ? placeholders and .bind(...):
const stmt = env.DB.prepare(
"SELECT * FROM users WHERE email = ? AND active = ?"
).bind(email, 1);
const { results } = await stmt.all();Or named parameters:
const stmt = env.DB.prepare(
"SELECT * FROM users WHERE email = ?1 AND active = ?2"
).bind(email, 1);Common Mistake: Building queries with string concatenation. SQL injection works against D1 the same way as any other database:
// DON'T:
env.DB.prepare(`SELECT * FROM users WHERE email = '${email}'`).all();
// DO:
env.DB.prepare("SELECT * FROM users WHERE email = ?").bind(email).all();For multiple rows insert, use batch:
const stmt = env.DB.prepare("INSERT INTO logs (level, message) VALUES (?, ?)");
await env.DB.batch([
stmt.bind("info", "started"),
stmt.bind("error", "failed"),
stmt.bind("info", "stopped"),
]);batch runs all statements in a single round-trip and is transactional — either all succeed or none do.
Fix 6: Enable Foreign Keys
SQLite — and therefore D1 — has foreign keys disabled by default. Your REFERENCES clauses are checked at table-creation time but not enforced on writes.
Enable them per connection:
await env.DB.exec("PRAGMA foreign_keys = ON");
const { results } = await env.DB.prepare(...).all();Or include the PRAGMA at the top of every migration that defines foreign keys:
PRAGMA foreign_keys = ON;
CREATE TABLE posts (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
);Note: PRAGMA foreign_keys = ON is per-connection. In Workers, the connection is short-lived (per request), so you need to enable it on every request that does writes touching FKs. Wrap it in your DB helper.
Fix 7: Handle Concurrent Writes
D1 serializes writes per database. Many concurrent Workers hitting the same DB will queue, and individual statements that depend on a previous read are subject to race conditions:
// RACE: two requests can both pass the check.
const exists = await env.DB.prepare("SELECT 1 FROM users WHERE email = ?")
.bind(email).first();
if (!exists) {
await env.DB.prepare("INSERT INTO users (email) VALUES (?)").bind(email).run();
}Two fixes — pick one:
Use a UNIQUE constraint and catch the error:
try {
await env.DB.prepare("INSERT INTO users (email) VALUES (?)").bind(email).run();
} catch (e) {
if (e.message.includes("UNIQUE constraint failed")) {
// Already exists. Read and return.
} else {
throw e;
}
}Use INSERT ... ON CONFLICT:
await env.DB.prepare(`
INSERT INTO users (email) VALUES (?)
ON CONFLICT (email) DO NOTHING
`).bind(email).run();The second is cleaner — no exception handling, atomic in a single statement.
Fix 8: CLI Quoting on Windows
wrangler d1 execute --command="..." works fine on macOS/Linux. On Windows PowerShell, the quotes get mangled:
wrangler d1 execute my-app-prod --command="SELECT * FROM users WHERE email = '[email protected]'"
# Error: near "@bar": syntax errorUse --file instead — write the SQL to a file and reference it:
wrangler d1 execute my-app-prod --file=./query.sqlFor one-off SELECTs from PowerShell, escape the inner quotes:
wrangler d1 execute my-app-prod --command='SELECT * FROM users'(Outer single quotes, inner whatever PowerShell accepts.)
Still Not Working?
A few less-obvious failures:
SqliteError: too many SQL variables. D1 has a parameter limit (~100). For bulk inserts, batch them in chunks or use a singleINSERT ... VALUES (?,?), (?,?), ...per chunk.Network erroron first request after deploy. D1 takes a few seconds to propagate new bindings globally. Retry — usually clears within 30 seconds.undefinedfrom.first()instead ofnull..first()returnsnullfor no-rows, notundefined. If you seeundefined, the query itself errored silently — checkawait.- Date columns store as text/integer with no obvious type. SQLite has no native
DATETIME. Store asINTEGER(unix epoch) orTEXT(ISO 8601) and convert in your code. Don’t assumeDateround-trips. SELECT changes()returns 0 afterUPDATE. D1’sprepare().run()returns{ meta: { changes, last_row_id } }. Readmeta.changes, not a separateSELECT changes()(which is unreliable across statements).- Approaching the per-database storage cap. D1 has plan-dependent storage limits per database. Check current limits in the Cloudflare dashboard. Vacuum:
wrangler d1 execute my-app-prod --remote --command="VACUUM". For production, consider sharding across multiple D1s if you outgrow the limit. - Migrations run out of order. D1 applies in filename order — name them with zero-padded prefixes (
0001_,0002_) so0010_doesn’t sort before0002_. - Time Travel restore not visible in dashboard.
wrangler d1 time-travel restoreis a CLI feature. Checkwrangler d1 time-travel infofor the bookmark, then restore:wrangler d1 time-travel restore my-app-prod --bookmark=....
For related Cloudflare and SQLite issues, see Wrangler not working, SQLite database is locked, Drizzle ORM not working, and Hono not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Cloudflare R2 Not Working — Bindings, S3 API Auth, CORS, Presigned URLs, and r2.dev Limits
How to fix Cloudflare R2 errors — env.MY_BUCKET undefined in Workers, S3 SDK signature mismatch, multipart upload size limits, CORS on direct uploads, presigned URL generation, and r2.dev rate limits.
Fix: Cloudflare Durable Objects Not Working — ID Strategy, Storage API, WebSocket Hibernation, Alarms
How to fix Cloudflare Durable Objects errors — idFromName vs newUniqueId, Storage transactions, blockConcurrencyWhile, WebSocket Hibernation API, alarms, migrations, and class binding setup.
Fix: Cloudflare Pages Not Working — Build Output, Functions Routing, _redirects, and Bindings
How to fix Cloudflare Pages errors — build output directory mismatch, Functions in /functions/, _redirects vs _headers, compatibility flags, env per branch, D1/R2/KV bindings, and Direct Upload alternatives.
Fix: Cloudflare Queues Not Working — Producer Binding, Consumer Worker, Batching, and Dead Letter
How to fix Cloudflare Queues errors — producer queue.send not delivering, consumer not invoking, ack/retry/DLQ patterns, batch size limits, max_retries, content type pitfalls, and local dev with wrangler.