Skip to content

Cloudflare D1 alternatives: what you are actually coupled to

15 min readCloudflare D1SQLitelibSQLDatabases

Cloudflare D1 is SQLite, and that is most of why people pick it. Ordinary SQL, a database that costs nothing to start, and if your application is already a Worker it is one binding away from working. For a Worker-shaped application it is a genuinely good product, and the parts of it that people complain about are mostly the parts where their application stopped being Worker-shaped.

That is the moment that brings someone to a post like this. A background job in a container. A CI step that seeds test data. A migration runner. An analytics script on a laptop. Each one is a small discovery about what D1's shape actually is, and none of them is the fault of the database.

So this is an inventory rather than a complaint: what you are coupled to, what you are not, why getting data out of D1 turns out to be a real engineering problem, and then the options, including staying put, which is the right answer more often than a post with this title usually admits.

Contents

What you are actually coupled to

There is no host and port

D1 has no wire protocol. There is nothing for sqlite3, DBeaver, TablePlus, or your ORM's migration runner to connect to, because there is no endpoint of the kind those tools expect. You reach a D1 database two ways: a Workers binding (env.DB) from inside Cloudflare's runtime, or Cloudflare's REST API with an account-scoped API token. Wrangler is a client of the second one, not a third path.

This is a design decision rather than an oversight, and it explains everything downstream. SQLite has never had a network protocol; it is a library you link into a process. D1 puts that library inside Cloudflare's runtime and hands you a binding, exactly like KV, R2, and Durable Objects. The colocation is where the latency story comes from, and you cannot have the colocation and a listening socket at the same time.

The consequence is that every consumer outside a Worker needs an adapter, and you are the one writing it. In practice that means a Worker whose entire job is to be a database driver for the rest of your stack. It is not much code. It is code you own, with its own auth, deploy, error surface, and on-call story, sitting between your application and your data.

The exit is the narrow part

Cloudflare documents two ways to get bulk data out, and both have edges.

wrangler d1 export produces a SQL dump. It is also lossy: any integer above 2^53 comes back as a different number, because the dump renders values through a JavaScript number on the way into the file. We measured 9223372036854775807 coming back as 9223372036854776000, and then flipping storage class from integer to real on reimport. Cloudflare does document the cause, in one sentence on their import and export page: "Any numeric value in a column is affected by JavaScript's 52-bit precision for numbers." The full measurement is in Cloudflare D1 exports round every integer past 2^53, so I will not re-derive it here.

The same page carries two more export limitations worth knowing before you plan around it: "Export is not supported for virtual tables, including databases with virtual tables", which means an FTS5 table anywhere in the schema stops the export outright, and "A running export will block other database requests", which means exporting a production D1 is scheduled downtime rather than a background task.

The other path is the REST query API. It is exact if you drive it carefully, and it is rate limited alongside every other Cloudflare API call on the account: 1,200 requests per five minute period per user, with a 429 blocking all of your API calls for the next five minutes. That budget is shared with the human clicking around the dashboard.

What is not on the list is the thing people assume must exist somewhere: a copy of the underlying SQLite file. Your data is in a SQLite database and you cannot have it as one. Time Travel is real point-in-time recovery and it is always on, which is a genuinely good feature, but it restores in place, into D1. It is a safety net, not an export.

The billing model is a coupling point too

D1 meters rows read and rows written, and rows read "measure how many rows a query reads (scans), regardless of the size of each row." A WHERE clause on an unindexed column bills every row it scanned to find the ones it returned. The daily free allowance covers most small projects and the per-million rate is low, but it does mean a missing index is a billing event rather than only a latency one, and a query-shape change needs a cost review as well as a code review. Turso users will recognize the class of surprise; I wrote about that side of it in Turso alternatives.

What you are not coupled to

Now the fair half, and it matters more than the list above.

Your SQL is portable. Schema, indexes, triggers, views, and every query you have written are ordinary SQLite. Wrangler's migration ledger is an ordinary table. Nothing in your query layer needs a rewrite to run somewhere else, because there is no proprietary dialect, no vendor extension you got quietly enrolled in, and no stored-procedure language to port.

That puts D1 in a much better position than most managed databases people call lock-in. The coupling is entirely at the transport and egress layer, which is a real problem and a much smaller one than a dialect rewrite.

Getting the data out is harder than it looks

This section exists because "how do I get my data out" is the question every option below hinges on, and for D1 it does not have a one-command answer. We wrote the D1 importer for the Layerbase migration wizard. I estimated two days. It was not two days, and the reasons are all things you would hit too.

The vendor's own export is off the table, so you are writing a reader. Once the dump is disqualified for precision, the only remaining source is the query API, which turns a data copy into a paging problem with a network and a rate limiter in the middle. Everything below follows from that.

The API has hard edges you design around, not handle. A SQL statement over 100,000 bytes is rejected, and a result set is capped at 100 columns. The second is not something you retry past: a table may legally have 100 columns, so any read query that emits two output columns per source column (the obvious shape, if you want the value and its type) is dead on arrival for a wide table. That cap dictates what the query can look like, which is the kind of constraint that shows up after you have written it.

You cannot see your remaining rate-limit budget. A successful response carried no rate-limit headers when we measured it, so there is no counter to read. You pace against a budget you assume is safe, treat a 429 as evidence the assumption was wrong, and leave headroom for the human whose dashboard clicks draw on the same allowance.

Reads do not block, which is good and inconvenient. The source keeps serving traffic while you copy, which is the whole reason to prefer this over the export. It also means there is no snapshot, so the honest response is to verify rather than claim: count rows before and after, per table, and report anything that moved while you were reading it.

Nothing numeric can touch JavaScript. The encoding has to happen inside the database, so every value crosses the wire as text SQLite itself produced. The recipe (typeof() alongside each value, integers as exact decimal text, reals through printf('%!.20g'), text and blobs as hex) is written out in the precision post. It is short. Arriving at it took considerably longer than reading it does.

Then the long tail, which is where the time goes. Infinity is a legal SQLite value with no JSON representation, so it has to go into the target as a SQL literal rather than as something the transport carries. A TEXT column can hold bytes that are not valid UTF-8, which SQLite permits and real schemas contain, and any layer that decodes the payload as UTF-8 corrupts them silently. FTS5 virtual tables bring shadow tables, which must not be copied directly and must not be mistaken for user tables; the rule for telling them apart is SQLite's own, split at the last underscore and check that the prefix names a virtual table and the suffix is one that module reserves. Get it loose in the wrong direction and a real user table called notes_data_extra vanishes from the copy without anything erroring.

None of that is exotic. It is a list of ordinary details a naive reader gets wrong, and every one of them produces a migration that looks successful. Row counts match. Nothing throws. Some values are different.

If you are writing your own extraction, budget accordingly and test with hostile data rather than your schema's happy path. If you would rather not, the D1 migration does this and reports what it verified.

Option 1: Managed libSQL on Layerbase

Layerbase Cloud hosts libSQL, the open source SQLite fork that adds the network protocol SQLite never had. A database is an HTTPS endpoint plus an auth token, so the same database is reachable from a Worker, a Vercel function, a long-running container, a CI job, and your laptop, with the same URL.

The client is the standard one:

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)

On Drizzle it is a driver swap and nothing else, because the SQL does not change. Migrating from Cloudflare D1 to Layerbase has the before and after.

Beyond the endpoint: flat per-instance pricing with no read or write metering, a web SQL console, backups, and database branching. libSQL is one of the 18 engines Layerbase Cloud runs, so it sits in the same account as your Postgres or Redis rather than in another vendor's tab. The D1 migration needs a Cloudflare account ID and an API token with D1 Read, and it does the extraction described above for you.

Where this is the wrong answer: if your reads need to be milliseconds from users on three continents, a single-region database will not match what D1 does from Cloudflare's edge. That is a real trade and we do not have an equivalent.

Option 2: Turso

Turso is the company that built libSQL, and Turso Cloud is the most mature managed libSQL there is. If you want embedded replicas, where a local copy of the database sits inside your application process and syncs, nobody else offers that. It is a genuinely distinctive architecture and for read-heavy applications it is very fast.

Two things to know going in. Turso's pricing meters rows read, which is the same class of surprise as D1's metering and, for scan-heavy queries, a sharper one, so audit your access patterns before you commit rather than after. And Turso now ships two SQLite-evolution projects: libSQL is the C fork, production ready, and what Turso Cloud runs today, while Turso Database is a from-scratch rewrite of SQLite in Rust, currently in beta, and where the company's new engineering energy is going. That is a reasonable bet on their part, and it is fine to adopt early on purpose. It is less fine to discover it by accident.

Migrating D1 to Turso is possible, and you own the extraction problem: Turso's import wants a SQLite file, and the obvious way to produce one from D1 is wrangler d1 export, which puts you back on the lossy path. If every id you have is below 2^53 that is fine. If not, read the precision post before you plan the cutover.

Option 3: Self-hosted sqld

libSQL's server is open source. sqld runs on any VM, speaks the same HTTP protocol as Turso Cloud and Layerbase, and works with the same @libsql/client:

bash
sqld --http-listen-addr 0.0.0.0:8080 --grpc-listen-addr 0.0.0.0:5001

This is a real option and I do not want to talk anyone out of it. For a small workload on a box you already run, it is a modest amount of operational surface, and you get an endpoint your whole stack can reach without a vendor in the middle.

The parts people underestimate: TLS and token management are yours, backups are yours and nobody will remind you, and the interesting libSQL features (replication, vector search) need deliberate setup rather than a checkbox. Set up off-host backups before you put anything you care about in it. The failure mode of self-hosting is rarely the database process, it is the disk you never took a copy of.

Option 4: Plain SQLite on a server you own

Worth naming because it is frequently the correct answer and people skip past it. If your application is a single process on a single machine, you do not need a network protocol at all. SQLite in-process with better-sqlite3 or your language's binding is faster than anything with a socket in front of it, and it is the most-deployed database in the world.

You reach for libSQL over plain SQLite when you need network access from more than one machine, replication, or vector search. If none of those describe you, the honest recommendation is a file on a disk with a backup job, and D1 was solving a problem you did not have.

Layerbase Cloud hosts plain SQLite too, if you want the file managed without the libSQL layer on top.

Option 5: Stay on D1

The option every post like this skips, so let me make the case properly.

Stay on D1 if your application is a Worker and looks likely to remain one. The binding is genuinely the nicest way to reach a database from inside that runtime: no connection pool, no cold-start handshake, no credentials in flight. Time Travel gives you a 30-day point-in-time restore on paid plans without configuring anything, which is better than the backup story a lot of teams have on databases they run themselves. Read replication is built in at no extra cost, though it does require adopting the Sessions API, since without it your queries all keep going to the primary.

The rest of the coupling above only bills you when you leave the Worker. If you never leave, you never pay it.

The signals that you are drifting out of D1's sweet spot are specific rather than vague: you have written a Worker whose only purpose is to proxy queries for something else; you find yourself planning around a 5 GB per-account storage ceiling on the free plan, or 10 GB per database on paid; your row-read counter has become something you check; or you have 64-bit ids and a migration somewhere in your future and just realized what that combination means.

The exit matters more than the destination

Here is the argument I actually want to make, and it is not "our thing is better than their thing."

When you leave a managed database, the question that matters is not which vendor you land on. It is whether you land in a format or in another product. Land in a product and you have swapped one exit problem for another, and you will do this again in two years.

The destination in a D1 migration is libSQL, the open source fork of SQLite. The database you end up with answers to the standard @libsql/client, which also talks to Turso Cloud and to an sqld you run yourself, and what sits on disk is an ordinary SQLite database. Your next move, whenever it comes and whoever it is toward, starts from a file that sqlite3 opens and every language has a driver for. That is different from being portable in principle. It is portable in the specific sense that the bytes are in a documented, ubiquitous format nobody controls.

Which is also why the fidelity work was worth doing. An exit into a standard format is only worth something if the values that arrive are the values you had. A migration that quietly rounds your snowflake ids has handed you a perfectly portable copy of the wrong data.

Which one to pick

  • Application is a Worker, and staying one? Stay on D1. The binding is good and the coupling never bills you.
  • Need the database reachable from everything else you run, with flat pricing? Layerbase Cloud libSQL.
  • Want embedded replicas in-process and have audited your read patterns? Turso.
  • Small workload, a box you already operate, and you will actually configure backups? Self-hosted sqld.
  • Single process, single machine? Plain SQLite. No network layer required.

If you are moving off D1, start at layerbase.com/migrate/cloudflare-d1. The step-by-step, including what changes in your application code, is in Migrating from Cloudflare D1 to Layerbase, and the measurement behind the fidelity claims is in Cloudflare D1 exports round every integer past 2^53.