Skip to content

Fix: Cloudflare D1 Not Working — Binding Errors, Local vs Remote, Migrations, and Foreign Keys

FixDevs ·

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: users

Or 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). If wrangler.toml doesn’t declare it, or the database ID doesn’t match, env.DB is undefined at runtime.
  • Local vs remote. wrangler dev defaults to a local SQLite file under .wrangler/state/. wrangler dev --remote and 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-hoc wrangler 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 list

Generate fresh types so TypeScript knows about the binding:

wrangler types

This 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 DB

Add --remote to hit production:

wrangler d1 execute my-app-prod --remote --command="SELECT * FROM users"
wrangler d1 migrations apply my-app-prod --remote

To test the Worker against the remote DB:

wrangler dev --remote

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

Edit 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     # Production

D1 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-prod

This 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 error

Use --file instead — write the SQL to a file and reference it:

wrangler d1 execute my-app-prod --file=./query.sql

For 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 single INSERT ... VALUES (?,?), (?,?), ... per chunk.
  • Network error on first request after deploy. D1 takes a few seconds to propagate new bindings globally. Retry — usually clears within 30 seconds.
  • undefined from .first() instead of null. .first() returns null for no-rows, not undefined. If you see undefined, the query itself errored silently — check await.
  • Date columns store as text/integer with no obvious type. SQLite has no native DATETIME. Store as INTEGER (unix epoch) or TEXT (ISO 8601) and convert in your code. Don’t assume Date round-trips.
  • SELECT changes() returns 0 after UPDATE. D1’s prepare().run() returns { meta: { changes, last_row_id } }. Read meta.changes, not a separate SELECT 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_) so 0010_ doesn’t sort before 0002_.
  • Time Travel restore not visible in dashboard. wrangler d1 time-travel restore is a CLI feature. Check wrangler d1 time-travel info for 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles