Skip to content

Migrating from Cloudflare D1 to Layerbase

5 min readCloudflare D1libSQLSQLiteMigrations

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.

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.

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.

Migrate a D1 database or read the migration docs.