How to run a Cloudflare D1 database locally
By Flavio Copes
Run Cloudflare D1 locally with Wrangler and Miniflare, apply migrations, seed data, inspect SQLite state, and keep development separate from production.
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:
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:
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:
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 first.
Create a D1 database:
npx wrangler d1 create notes-db
Wrangler prints the database ID and the configuration we need. Add it to wrangler.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:
bindinggives usenv.DBin the Workerdatabase_nameis the name used by Wrangler commandsdatabase_ididentifies 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:
npx wrangler d1 migrations create notes-db create_notes
Wrangler creates a numbered SQL file inside migrations/.
Put this schema in that file:
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:
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:
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:
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:
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:
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:
npx wrangler dev
Wrangler usually starts it at http://localhost:8787.
Open that URL or call it with curl:
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:
.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:
.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/statecontains 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:
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:
npx wrangler d1 migrations apply notes-db \
--local \
--persist-to=./local-state
The same applies when we seed or inspect it:
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:
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:
{
"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:
{
"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:
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:
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:
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:
{
"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:
- Change the schema in a migration
- Apply the migration with
--local - Run or seed the local database
- Test through
wrangler devand the Workers Vitest integration - Apply the reviewed migration with
--remotewhen 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, local binding data, and D1 migrations for the current command reference.
Want me to talk about your product? You can sponsor this site.
Related posts about cloudflare: