Skip to content

Migrating from Cloudflare D1 to Layerbase

9 min readCloudflare D1libSQLSQLiteMigrations

Short version: you move to managed libSQL, which is SQLite, so your schema and your SQL come across unchanged and the only real code change is the client: @libsql/client with a URL and a token in place of the D1 binding. Paste a Cloudflare account ID and an API token with D1 Read, pick a database from the list, and the copy runs in one pass while your Worker keeps serving, because we read over D1's query API rather than blocking the database the way wrangler d1 export does. Do not use that export to move yourself: it renders values through a JavaScript number and silently rounds any integer past 2^53. Two things do not carry over cleanly. Full-text search tables arrive without their index content, so you rebuild after the import, and the read is paged rather than a point-in-time snapshot, so a database taking live writes can end up uneven across tables. We verify row counts per table and report that instead of hiding it, but if you need an exact instant, pause writes first.

Cloudflare D1 is a good product with one sharp edge: it is designed to be read from a Worker. If your application is a Worker, that is invisible. If it is anything else, you end up writing a Worker whose only job is to be a database driver for the rest of your stack.

That is the usual reason people move. The reason we ended up writing this post is different. While building the D1 importer, we found that D1's own SQL export does not round-trip your data.

Just want it done? Start at layerbase.com/migrate/cloudflare-d1. You sign in, the wizard opens with Cloudflare D1 already selected, and it copies your schema and data into managed libSQL with every SQLite type intact: read-once, nothing written back to D1, and your Worker keeps serving throughout. The rest of this post is that same migration explained step by step.

The export is not lossless

Store the largest signed 64-bit integer in a D1 table:

sql
CREATE TABLE t (id INTEGER PRIMARY KEY, big INTEGER, r REAL);
INSERT INTO t VALUES (1, 9223372036854775807, 1.0);

Ask D1 what it holds, casting to text so the value is not routed through a number on the way back:

sql
SELECT CAST(big AS TEXT), typeof(big) FROM t;
text
9223372036854775807|integer

Correct. Now export the database with wrangler d1 export and read the dump:

sql
INSERT INTO "t" ("id","big","r") VALUES(1,9223372036854776000,1);

The value changed. 9223372036854775807 became 9223372036854776000, off by 193. Load that dump into SQLite and the damage compounds: the corrupted literal no longer fits in a signed 64-bit integer, so SQLite stores it as a float and typeof flips from integer to real.

The cause is that the dump renders values through a JavaScript number, which is a double, which is exact only up to 2^53. Cloudflare does document the underlying limit, in a single sentence on their import and export page: "Any numeric value in a column is affected by JavaScript's 52-bit precision for numbers." That sentence is easy to read as a footnote about the JSON API. It also means the file you were about to migrate with is wrong.

Who this actually hurts: anyone using snowflake ids, nanosecond timestamps, bitfields, or an external system's 64-bit identifiers. Nothing errors. Row counts match. The values are just quietly different.

The REAL column has a milder version of the same problem. 1.0 exports as bare 1. If the column is declared REAL, SQLite's type affinity converts it back on import and you get away with it. If the column has no declared type, it stays an integer and the storage class has changed underneath you.

What we do instead

The Layerbase importer never touches the export endpoint. It reads your rows over D1's query API, and it does the type-preserving encoding inside SQLite, before anything crosses the wire:

  • integers as exact decimal text
  • floats through printf('%!.20g', ...), which round-trips a double, unlike CAST(x AS TEXT) which truncates to 15 significant digits
  • text and blobs as hex
  • typeof() alongside each value, so the storage class is carried explicitly rather than guessed

Every value arrives as ASCII text or NULL. Nothing numeric is ever handed to a JavaScript number, which is the only way to make this exact rather than nearly exact.

Two consequences worth knowing, because they are both improvements over the export path:

Your D1 stays online. Cloudflare's export blocks other requests to the database while it runs, which for a production D1 means downtime you have to schedule. Reads do not block, so the copy runs while your Worker keeps serving.

Full-text search stops being a blocker. Export refuses outright on a database containing a virtual table. A read-based copy does not, so an FTS5 table is recreated and flagged for a rebuild instead of stopping the migration.

The honest trade-off in exchange: because we page through your tables while they stay writable, the copy is not a single point-in-time snapshot. We count rows before and after and tell you, per table, if anything was written mid-copy. Migrate from a quiet database, or pause writes, if you need an exact instant.

Doing the migration

In the Layerbase dashboard, pick Cloudflare D1 as a migration source. You need two things:

  1. Your Cloudflare account ID, from the dashboard URL or the Workers and Pages sidebar.
  2. An API token with the D1 Read permission, scoped to that account. Create it under My Profile, then API Tokens, then Create Custom Token.

Read is genuinely all we need, because the importer only issues SELECT and PRAGMA. Do not use a Global API Key: it is not a bearer token and will fail authentication.

We then list the D1 databases on your account so you pick one from a list rather than pasting a UUID. Provisioning the target libSQL database and copying the data is one pass from there, and the completion screen reports what happened: row counts verified per table, generated columns recomputed, any FTS index that needs rebuilding, and any table that changed size while it was being read.

What changes in your code

The SQL does not change, because libSQL is SQLite. What changes is how you connect. If you were using the D1 binding through Drizzle, it is a driver swap:

ts
// before
import { drizzle } from 'drizzle-orm/d1'
const db = drizzle(env.DB)

// after
import { createClient } from '@libsql/client'
import { drizzle } from 'drizzle-orm/libsql'

const client = createClient({
  url: process.env.LAYERBASE_DATABASE_URL!,
  authToken: process.env.LAYERBASE_AUTH_TOKEN!,
})
const db = drizzle(client)

Without an ORM, @libsql/client replaces the binding directly:

ts
import { createClient } from '@libsql/client'

const client = createClient({
  url: process.env.LAYERBASE_DATABASE_URL!,
  authToken: process.env.LAYERBASE_AUTH_TOKEN!,
})

const result = await client.execute('SELECT id, email FROM users LIMIT 10')
console.log(result.rows)

The important difference is not the syntax. It is that this client works from a Worker, a Vercel function, a long-running container, a CI job, and your laptop, using the same URL and token. There is no binding to declare and no platform to be inside of.

A few things to check before you switch

Generated columns. We detect them and let libSQL recompute them, so they stay generated instead of becoming frozen values. Worth verifying on your side anyway if you rely on them for constraints.

d1_migrations. Wrangler keeps its migration ledger in an ordinary table, so it copies across like any other. That means the new database claims your wrangler migration history. If you are leaving wrangler behind, drop it.

Full-text search. The tables come across; the index content does not. Rebuild after the import.

Your ids. If you have integers above 2^53 and you previously migrated anything via wrangler d1 export, it is worth checking those values against the source. That is the whole reason this post exists, and the measurement behind it includes the two queries that tell you whether a past migration lost anything.

FAQ

Does my D1 database go down while the migration runs?

No. The importer only issues SELECT and PRAGMA through D1's query API, so reads do not block and your Worker keeps serving the whole time. This is the opposite of Cloudflare's own export, which blocks other requests to the database while it runs and therefore has to be scheduled as downtime for anything in production.

Is the copy an exact point-in-time snapshot of my data?

No, and that is the one caveat worth planning around. We page through your tables while they stay writable, so rows written partway through the copy can land unevenly across tables. Row counts are checked per table after the copy and anything that changed size mid-read is reported on the completion screen rather than passing silently. If you need an exact instant, migrate from a quiet database or pause writes for the few minutes it takes.

What does not come across from D1?

Values and types come across exactly, including integers above 2^53, which is more than D1's own export manages. Generated columns stay generated rather than freezing into literals. The gap is full-text search: FTS5 tables are recreated but their index content is not copied, so rebuild the index after the import. d1_migrations copies like any other table, which means your new database inherits the wrangler ledger; drop it if you are leaving wrangler behind.

What happens to my Worker and the D1 binding?

The binding goes away and you connect over the network instead, with the same URL and auth token from a Worker, a Vercel function, a container, a CI job, or your laptop. With Drizzle it is a one-import driver swap. Without an ORM, @libsql/client replaces env.DB directly. The SQL itself does not change, because libSQL is SQLite.

Does read volume still show up on the bill?

No. Managed libSQL on Layerbase is flat-priced with a free tier, so the number of rows your app reads stops being a billing input the way D1's read units are. That is usually the second reason people move, after the coupling to Workers.

Where this leaves you

Managed libSQL on Layerbase is flat-priced with a free tier, so read volume stops being a billing input, and it sits in the same account and dashboard as the other 18 engines we run in the cloud, with a web SQL console, backups, and branching.

If you are on Workers and happy, D1 is a reasonable place to be. If you have started writing glue to reach your own database, or you have 64-bit ids and a migration in your future, moving is straightforward and the fidelity question is already answered.

Still deciding rather than moving? Cloudflare D1 alternatives lays out the coupling points, the other places SQLite can live (Turso, self-hosted sqld, plain SQLite), and the case for staying on D1.

Migrate a D1 database or read the migration docs.