# How to run a Cloudflare D1 database locally

> Run Cloudflare D1 locally with Wrangler and Miniflare, apply migrations, seed data, inspect SQLite state, and keep development separate from production.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-29 | Updated: 2026-08-03 | Topics: [Cloudflare](https://flaviocopes.com/tags/cloudflare/) | Canonical: https://flaviocopes.com/run-cloudflare-d1-locally/

Cloudflare D1 is a managed database, but we don't have to connect to Cloudflare every time we develop our app.

Wrangler can run a separate D1 database on our computer. Our Worker uses the same `env.DB` binding it uses in production, but all queries go to local data.

This gives us a very nice development setup. We don't need Docker, a database server, a connection string, or a copy of the production database.

Let's see how it works.

## What runs on our computer

When we run this command:

```bash
npx wrangler dev
```

Wrangler starts a local Cloudflare Workers development environment.

There are 3 important pieces involved:

- **workerd** runs our Worker code using Cloudflare's open source runtime
- **Miniflare** creates local versions of bindings such as D1, KV, and R2
- **SQLite** stores the local D1 data on disk

Our code does not open the SQLite file directly.

Cloudflare says local development runs the same D1 version used on its global network. This gives us the same database API and query behavior, without sending every query over the internet.

It talks to `env.DB`, which is a D1 binding. Miniflare connects that binding to the local database.

The flow looks like this:

```text
request
  -> local Worker running in workerd
  -> env.DB binding
  -> Miniflare's local D1 implementation
  -> local SQLite data
```

In production, the code stays the same. Cloudflare connects `env.DB` to the managed D1 database instead:

```text
request
  -> deployed Worker
  -> env.DB binding
  -> Cloudflare D1
```

This is the key idea.

**The binding is the stable interface. The database behind it changes.**

We don't need an `if` statement that checks if we're in development. We don't need a different SQLite library locally. We also don't need to change our queries before deploying.

## Add D1 to a Worker project

I'll assume we already have a Cloudflare Worker project and Wrangler installed.

If D1 is new to you, read my [introduction to Cloudflare D1](https://flaviocopes.com/cloudflare-d1/) first.

Create a D1 database:

```bash
npx wrangler d1 create notes-db
```

Wrangler prints the database ID and the configuration we need. Add it to `wrangler.jsonc`:

```jsonc
{
  "name": "notes-api",
  "main": "src/index.js",
  "compatibility_date": "2026-08-20",
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "notes-db",
      "database_id": "replace-this-with-the-database-id"
    }
  ]
}
```

The 3 D1 values have different jobs:

- `binding` gives us `env.DB` in the Worker
- `database_name` is the name used by Wrangler commands
- `database_id` identifies the remote database on Cloudflare

That remote ID does not mean local development connects to production.

By default, `wrangler dev` creates and uses a local-only D1 database. The remote ID is used when we deploy or explicitly run a remote command.

## Create the first migration

We should keep the database schema in migration files.

Create the first migration:

```bash
npx wrangler d1 migrations create notes-db create_notes
```

Wrangler creates a numbered SQL file inside `migrations/`.

Put this schema in that file:

```sql
CREATE TABLE notes (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  title TEXT NOT NULL,
  body TEXT NOT NULL DEFAULT '',
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX notes_created_at_index
ON notes(created_at);
```

Now apply the migration to the local database:

```bash
npx wrangler d1 migrations apply notes-db --local
```

I always write `--local` or `--remote` explicitly.

Without `--local`, a D1 command can target the remote database. The flag is not decoration. It is the boundary between disposable development data and Cloudflare data.

Wrangler also creates a `d1_migrations` table. It records which migration files were applied, so the same migration does not run twice.

## Add local seed data

A new local database starts empty.

Create a `seed.sql` file:

```sql
INSERT OR IGNORE INTO notes (id, title, body)
VALUES
  (1, 'Buy coffee', 'Get a bag of espresso beans'),
  (2, 'Write post', 'Explain how local D1 works');
```

The fixed IDs and `OR IGNORE` make this small seed file safe to run more than once.

Load it into the local database:

```bash
npx wrangler d1 execute notes-db --local --file=./seed.sql
```

This command only changes the local D1 database.

We can check the data from the terminal:

```bash
npx wrangler d1 execute notes-db --local \
  --command="SELECT * FROM notes"
```

Wrangler prints the rows as a table.

This is often enough for debugging. We don't need a separate database application just to check a value.

## Query the local database from the Worker

Let's add a small endpoint that returns every note:

```js
export default {
  async fetch(request, env) {
    const { results } = await env.DB.prepare(
      'SELECT * FROM notes ORDER BY created_at DESC'
    ).all()

    return Response.json(results)
  },
}
```

Start the local Worker:

```bash
npx wrangler dev
```

Wrangler usually starts it at `http://localhost:8787`.

Open that URL or call it with `curl`:

```bash
curl http://localhost:8787
```

The request runs through our Worker. The call to `env.DB.prepare()` reaches the local D1 database and returns the seed data.

Nothing in this code is local-only. If we deploy it, the same query runs against the remote D1 binding.

## Where the local database is stored

Wrangler stores local binding data inside this directory by default:

```text
.wrangler/state
```

This includes local D1 databases and other local resources created by Miniflare.

The data persists when we stop and restart `wrangler dev`. If we add a note today, it will still be there tomorrow.

Add the directory to `.gitignore`:

```text
.wrangler/
```

Don't commit local database state.

Migration and seed files belong in Git. The generated SQLite state does not.

This distinction makes the project reproducible:

- migrations describe the database structure
- seed files provide known development data
- `.wrangler/state` contains each developer's disposable local state

If we want a clean database, stop the dev server and remove the local state directory. Miniflare creates it again the next time it starts.

Be careful: removing all of `.wrangler/state` also resets local KV, R2, and other simulated bindings in that project.

## Use a custom local data directory

We can choose where Wrangler stores local state:

```bash
npx wrangler dev --persist-to=./local-state
```

This can be useful in a monorepo, in CI, or when we want several isolated test databases.

There is one easy mistake to make here.

Every command that needs the same local database must use the same path:

```bash
npx wrangler d1 migrations apply notes-db \
  --local \
  --persist-to=./local-state
```

The same applies when we seed or inspect it:

```bash
npx wrangler d1 execute notes-db \
  --local \
  --persist-to=./local-state \
  --file=./seed.sql
```

If the paths don't match, Wrangler uses different local state. This often produces a confusing `no such table` error because we migrated one database and started the Worker with another.

The custom state directory should also go in `.gitignore`.

## Local and remote D1 are separate databases

This is worth making very explicit:

| Action | Database used |
|---|---|
| `wrangler dev` | Local D1 |
| `wrangler d1 execute notes-db --local ...` | Local D1 |
| `wrangler d1 migrations apply notes-db --local` | Local D1 |
| `wrangler d1 execute notes-db --remote ...` | Cloudflare D1 |
| `wrangler d1 migrations apply notes-db --remote` | Cloudflare D1 |
| Deployed Worker using `env.DB` | Cloudflare D1 |

Local changes do not copy themselves to production.

If we add a table locally, we must still apply that migration remotely:

```bash
npx wrangler d1 migrations apply notes-db --remote
```

The migration file is shared. The migration history and data are not.

This separation is useful. We can insert bad data, drop tables, and test unfinished migrations without putting real users at risk.

## Don't use the remote database for normal local development

Wrangler supports remote bindings. We can set `"remote": true` on a D1 binding and make locally running code talk to the remote database:

```jsonc
{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "notes-staging",
      "database_id": "replace-this-with-the-staging-id",
      "remote": true
    }
  ]
}
```

I avoid this for normal development.

A bug in local code could modify production data. Development also becomes slower and depends on the network.

Use a separate remote staging database when we need to test Cloudflare's real infrastructure. Never point a development binding at production just because it is convenient. Keep the local database as the default for the fast development loop.

## Make the setup easy for the team

I like to put the common commands in `package.json`:

```json
{
  "scripts": {
    "dev": "wrangler dev",
    "db:migrate:local": "wrangler d1 migrations apply notes-db --local",
    "db:seed:local": "wrangler d1 execute notes-db --local --file=./seed.sql",
    "db:migrate:remote": "wrangler d1 migrations apply notes-db --remote"
  }
}
```

Now a new developer can prepare the project with:

```bash
npm install
npm run db:migrate:local
npm run db:seed:local
npm run dev
```

That's the entire database setup.

There is no database user to create. There is no port to configure. There is no service that must be installed and kept running in the background.

The repository contains the instructions needed to reconstruct the database.

## Test D1 inside the Workers runtime

The same approach works well in CI. Cloudflare recommends its Workers Vitest integration for most Worker and Pages Function tests.

A test job can start with empty local state, apply every migration, load a small seed file, and run requests against the local Worker.

The integration runs tests inside the Workers runtime and provides isolated local storage per test file. It can read the same migration files and apply them to a test D1 binding.

A small setup file can apply every migration before tests run:

```js
import { env } from 'cloudflare:workers'
import { applyD1Migrations } from 'cloudflare:test'

await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)
```

The Vitest configuration reads the migrations and passes them through a test-only binding:

```js
import path from 'node:path'
import { cloudflareTest } from '@cloudflare/vitest-pool-workers'
import { readD1Migrations } from '@cloudflare/vitest-pool-workers/config'
import { defineConfig } from 'vitest/config'

export default defineConfig({
  plugins: [
    cloudflareTest(async () => ({
      miniflare: {
        bindings: {
          TEST_MIGRATIONS: await readD1Migrations(
            path.join(import.meta.dirname, 'migrations')
          )
        }
      }
    }))
  ],
  test: {
    setupFiles: ['./test/apply-migrations.js']
  }
})
```

This tests more than calling a SQLite library directly. Our code runs with the D1 binding inside the Worker runtime.

It also catches missing migrations.

If a query expects a column that was added by hand but never added to a migration, a clean CI database fails immediately.

Keep each test file independent. Tests that depend on shared database state become order-dependent and harder to debug.

## What local D1 does not reproduce

Local D1 gives us the D1 API and SQLite behavior on our computer. It does not reproduce Cloudflare's entire distributed infrastructure.

It won't show us real network latency, database placement, production load, or the behavior of read replicas across locations.

For most application work, this is the right tradeoff. We get a fast local loop for queries, schema changes, and request handling.

Before a risky release, apply migrations to a separate staging database and run integration tests there too.

## A note for Cloudflare Pages

Pages projects also need a Wrangler configuration file to use D1 locally.

Add `preview_database_id` to the D1 binding:

```jsonc
{
  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "notes-db",
      "database_id": "replace-this-with-the-database-id",
      "preview_database_id": "DB"
    }
  ]
}
```

For Pages local development, `preview_database_id` can give the local database a stable preview identity. The binding itself remains `DB`.

The migration and seed commands stay the same. Pass `--local`, and Wrangler writes to the local database.

## The development loop

Once everything is configured, the daily loop is small:

1. Change the schema in a migration
2. Apply the migration with `--local`
3. Run or seed the local database
4. Test through `wrangler dev` and the Workers Vitest integration
5. Apply the reviewed migration with `--remote` when deploying

The part I like most is that our application only knows about `env.DB`.

Wrangler and Miniflare decide what sits behind that binding. During development it's a local SQLite-backed D1 database. In production it's Cloudflare's managed D1 service.

That small layer of indirection removes a surprising amount of setup.

See the Cloudflare documentation on [local D1 development](https://developers.cloudflare.com/d1/best-practices/local-development/), [local binding data](https://developers.cloudflare.com/workers/local-development/local-data/), and [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/) for the current command reference.
