Skip to content

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

FixDevs · (Updated: )

Part of:  Database Errors

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.

There’s a fifth, less-obvious layer worth understanding: D1 is SQLite over HTTP. Every env.DB.prepare(...).all() call from a Worker is a network request to the D1 service, not an in-process function call. That means network failures, regional latency, and Cloudflare’s internal routing all become part of your query path. A query that takes 5ms when measured locally with bind mounts might take 25-50ms in production when the Worker is in one PoP and D1 is in another. Most of the time this is fine; during incidents, it’s the difference between a snappy API and a request that times out under load.

The binding model also creates a subtle deploy hazard. A Worker deployment is atomic — Cloudflare promotes the new bundle globally within seconds. But binding configuration changes are applied as part of the deploy: if you rename DB to USERS_DB in wrangler.toml and deploy, every old Worker invocation finishes against the old binding name and every new one looks for the new name. If your code still references env.DB somewhere, those requests start failing with the “Cannot read properties of undefined” error. The blast radius for any D1-binding mistake is global and instant. That’s why production D1 incidents tend to look like a sudden, fleet-wide spike in 500s on every D1-backed route — there’s no gradual rollout buffer.

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

Production Incident Lens: Sitewide D1 Outage Pattern

The classic D1 production incident plays out in three acts. Act one: a routine deploy goes out with a wrangler.toml change — a renamed binding, a new database, or an updated compatibility_date. Act two: within seconds, error monitoring lights up with thousands of Cannot read properties of undefined from every D1 route. Act three: rollback. Because Cloudflare’s Workers deploy globally and instantly, you don’t have a canary buffer; the only mitigation is a fast rollback, which is itself a global deploy that takes another 30-60 seconds to propagate. In that window, every D1-backed endpoint returns 500.

The hardening playbook has three parts. First, never deploy schema changes and Worker code changes in the same release. Run migrations against --remote as a separate step, verify the schema with a sanity query, then deploy the Worker that uses the new schema. This is the same principle as decoupling database migrations from application releases on a traditional stack — it’s just easier to forget on Cloudflare because everything lives in one repo and one CI job. Second, keep a smoke test that hits a known-good D1 query right after deploy. A single SELECT 1 FROM users LIMIT 1 against the production binding is enough to surface a binding-name mismatch before users do.

Third, instrument query latency from inside the Worker (not just on the dashboard) and alert on the 99th percentile. D1 latency is bounded by network hops between PoPs; if a Cloudflare incident degrades cross-region routing, your D1 queries can quietly slow from 20ms to 200ms without any error code. By the time the dashboard updates, customers have already noticed. A per-Worker histogram of prepare().all() durations, exported to Workers Analytics Engine, gives you a 60-second early-warning signal.

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=....
  • A staging environment shares a binding name but points at a different D1. Multi-env wrangler.toml setups ([env.staging]) can accidentally route production code to a staging database during a misconfigured deploy. Always check wrangler whoami and the active env before applying any migration.
  • D1 query works locally but errors with D1_TYPE_ERROR remote. SQLite local accepts some loose typing that the D1 service rejects. Pass JS numbers as numbers and strings as strings — don’t rely on coercion.
  • Workers with nodejs_compat flag behave differently after deploy. A compatibility_date bump or flag change can subtly alter how Date and BigInt serialize into D1. Test any flag change against a representative read/write before promoting it.

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