Cloudflare D1 caps query results at 50 MB from November 4
On October 30, Cloudflare announced that D1 will enforce a maximum query result size of 50 MB. It starts on Wednesday, November 4, 2026. From that day, a query whose result is larger than 50 MB fails with this error instead of returning:
D1 query result exceeded the maximum response size limit of 50 MBCloudflare's own framing is that the limit "covers the 99.999th percentile of requests", so most D1 users will never see it. Import and export through the REST API are exempt. The advice in the changelog is to select only the columns you need instead of SELECT *, and to paginate large results with LIMIT and OFFSET.
I build Layerbase Cloud, which hosts libSQL, so I have an interest in where D1 users go next. Most of this post is about fixing the queries in place, because for most people that is the right move. The last part is for the people for whom this limit is one more reason on a list that was already getting long.
Which queries hit it
50 MB is a lot of rows for a normal table. A users table with ten short columns is a few hundred bytes a row, so you would need well over 100,000 rows in one result. The queries that get there tend to share a shape:
SELECT *on a table with a JSON, HTML, or blob column. Rows that carry a rendered page, an API response, or an uploaded file get to 50 MB in a few hundred rows.- Admin and export endpoints that return a whole table to the browser or to a CSV download.
- Backup or sync scripts that read tables through the query API rather than through the export endpoint, which is the exempt one.
- Analytics queries that return raw rows to be aggregated in JavaScript instead of letting SQL do the
GROUP BY.
A Worker has 128 MB of memory, so a 50 MB result parsed into JavaScript objects was already close to falling over before this change. If one of your queries is near the limit, it is probably also one of your slowest.
You can estimate a result's size before November 4 by asking SQLite to add up the bytes:
SELECT
COUNT(*) AS row_count,
SUM(LENGTH(CAST(body AS BLOB)) + LENGTH(CAST(title AS BLOB))) / 1e6 AS approx_mb
FROM pages
WHERE site_id = 42;The serialized result is somewhat larger than the raw bytes, so treat anything over about 30 MB as already at risk.
The fix, and the catch in OFFSET
Selecting fewer columns is the cheap fix. If the page only shows titles, don't return the bodies.
Pagination is the other fix, and here the changelog's suggestion of LIMIT and OFFSET has a cost that matters on D1 specifically. SQLite implements OFFSET 50000 by reading 50,000 rows and throwing them away, and D1 bills rows read by what the query scans, not by what it returns. Walk a large table 1,000 rows at a time with OFFSET and the total rows read grows with the square of the table size. Each page is correct and the bill is not.
Keyset pagination avoids it. Remember the last key you returned and start the next page after it:
CREATE TABLE pages (
id INTEGER PRIMARY KEY,
site_id INTEGER NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL
);
-- First page
SELECT id, title FROM pages
WHERE site_id = ?1
ORDER BY id
LIMIT 1000;
-- Every later page: pass the last id from the previous page
SELECT id, title FROM pages
WHERE site_id = ?1 AND id > ?2
ORDER BY id
LIMIT 1000;With an index on (site_id, id), each page reads about the 1,000 rows it returns, however deep into the table it is. That is the version I'd ship on any SQLite, D1 or not.
The limit is not the problem on its own
A 50 MB ceiling is reasonable, and every hosted database caps results somewhere. Layerbase libSQL does too, so page through big tables on our side as well. If D1 is working for you, adjusting a couple of queries is a much smaller job than moving databases.
What I'd look at is how the limit sits next to the rest of D1's limits page. I measured the Layerbase column on libSQL server 0.24.32, the version Layerbase Cloud runs, rather than copying it from docs:
| Limit | Cloudflare D1 (Workers Paid) | Layerbase libSQL |
|---|---|---|
| Concurrency | Each database runs one query at a time | Reads run in parallel; writes take turns, as in any SQLite |
| Row, string, or blob | 2 MB | About 5 MB per value |
| Bound parameters per query | 100 | 32,766 |
| Columns per table | 100 | 2,000 |
| SQL statement length | 100 KB | No 100 KB cap (a 500 KB statement runs) |
| LIKE or GLOB pattern | 50 bytes | 50,000 bytes |
| Arguments per SQL function | 32 | 127 |
| Database size | 10 GB, and the page says it cannot be increased | Set by your plan's storage, raised with Pro pool blocks or a dedicated server |
| Who can connect | A Worker binding, or the REST API | Any runtime, over HTTPS with an auth token |
| Billing | Rows read and rows written, with index updates counted as written rows | Flat plans, no per-row meter |
| Spending cap | None on Workers Paid | Not needed, because the price does not move with usage |
The concurrency row is the one I'd weigh most. Four 3-second reads sent at once to the same libSQL database finished in 4.1 seconds in total, where one-at-a-time would have taken 12. D1 also has a 30-second limit on a single query. A 59-second query completed on libSQL in my tests, but I haven't timed one through the hosted network path, so I'm leaving that row out rather than guessing.
Then there are the parts that are about shape rather than numbers. D1 is designed to be reached from a Worker binding. A container, a CI job, a migration runner, or a laptop has to go through the HTTP API or a Worker you write for the purpose, because there is no host and port to connect to. And getting your data out is harder than it looks: wrangler d1 export renders numbers through JavaScript, so any integer past 2^53 is silently rounded in the dump. I wrote up that precision bug separately, because it is the part people find out about after the migration.
None of these is a dealbreaker by itself. If your app is a read-heavy Worker with users on several continents, D1's edge reads are a real advantage and you should probably stay. The case for moving is when several of them apply to you at once.
Why libSQL is the natural next step
libSQL is the open source fork of SQLite. Your schema, your SQL, your indexes, and your migrations carry over, because it is the same dialect and the same file format. What changes is how you reach it and what you pay for.
- Any runtime connects. A Layerbase libSQL database is an HTTPS endpoint with an auth token.
@libsql/clientworks from a Worker, a Vercel function, a container, a cron job, or your terminal, so the database stops being something only one runtime can see. - No row metering. Plans are flat. A query that scans a million rows costs the same as one that scans ten, so a missing index is a performance problem to fix, not a line on the invoice.
- No traffic meter either. Neither side charges egress for this: Cloudflare doesn't bill bandwidth on D1, and Layerbase plans are flat-priced with published fair-use allowances rather than a per-GB rate.
- Room to grow. Database size is set by your plan's storage and goes up with pool blocks on Pro or a dedicated server, rather than a fixed 10 GB ceiling.
- A database with tools around it. A web SQL console, backups, branches you can reset, and TLS by default, in the same account as your Postgres or Redis if you run those too.
The cost comparison depends on your workload. The Layerbase Free plan is $0 for two databases that sleep after 15 idle minutes and wake on the next connection, which is plenty for development and side projects. Solo is $5/month and keeps one database always on. Workers Paid also starts at $5 a month and includes a lot of D1 usage, so a small read-heavy app may cost about the same on both. Where the gap opens is writes: past the included 50 million a month, D1 charges $1.00 per million rows written, and that is how D1 bills reach hundreds of dollars. On a flat plan the same workload costs the same as an idle one.
The trade going the other way: a Layerbase database lives in one region. Users far from it get the network round trip that D1's read replicas would have hidden.
Moving a D1 database to Layerbase
You don't need to export anything yourself, and you shouldn't use wrangler d1 export for it, for the rounding reason above. Start at layerbase.com/migrate/cloudflare-d1:
- Sign in (the Free plan is fine) and the migration wizard opens with Cloudflare D1 selected.
- Paste your Cloudflare account ID and an API token with
D1 Readon that account. We list the D1 databases on the account and you pick one. - We copy the schema and data into a new managed libSQL database. Integers cross as exact decimal text and blobs as hex, so 64-bit ids, nanosecond timestamps, and bitfields arrive exactly as stored.
- We compare row counts per table at the end and tell you if anything differs.
The importer only issues read statements, so your Worker keeps serving traffic while the copy runs. That also means the copy is not a point-in-time snapshot. If the database is taking writes, pause them for the copy or run it at a quiet hour. Full-text search tables arrive without their index content, so rebuild those after the import.
The code change is the client. In a Worker, the D1 binding becomes:
import { createClient } from '@libsql/client/web'
const db = createClient({
url: env.LIBSQL_URL,
authToken: env.LIBSQL_AUTH_TOKEN,
})
const { rows } = await db.execute({
sql: 'SELECT id, title FROM pages WHERE site_id = ? AND id > ? ORDER BY id LIMIT 1000',
args: [siteId, lastId],
})The full walkthrough, including the driver differences, is in Migrating from Cloudflare D1 to Layerbase. If you're weighing other options, Cloudflare D1 alternatives covers Turso, Postgres with Hyperdrive, and staying on D1.
FAQ
When does the D1 50 MB limit start?
Wednesday, November 4, 2026. Queries that return more than 50 MB fail from that day with "D1 query result exceeded the maximum response size limit of 50 MB".
Does the limit apply to D1 exports?
No. Cloudflare says import and export operations through the REST API are not subject to it. Ordinary queries sent through the REST query API are.
Will LIMIT and OFFSET pagination fix it?
It keeps each result under 50 MB, but on a large table OFFSET makes D1 read and bill every skipped row. Keyset pagination (WHERE id > ? ORDER BY id LIMIT n) reads only the rows it returns.
Can Layerbase convert my D1 database for me?
Yes. The D1 migration tool reads your D1 database with a D1 Read token and copies the schema and data into a new libSQL database on Layerbase, on any plan including Free.
Keep reading
- The importer said every row matched. Three tables were missing.We built importers from Cloudflare D1 and Turso into libSQL and found seven data-fidelity bugs. They turned out to be the same two bugs, and neither one is really about SQLite.
- Cloudflare D1 exports round every integer past 2^53We measured what a D1 SQL export does to a 64-bit id: 9223372036854775807 comes back as 9223372036854776000, and the storage class flips from integer to real. Cloudflare documents the cause in one sentence. Here is the measurement, and the encoding that round-trips instead.
- Migrating from Cloudflare D1 to LayerbaseD1 is great if your app is a Worker. If it is not, you are reaching your database through a function you did not want to write. Here is how to move a D1 database to managed libSQL, and why we do not use D1's own export to do it: the SQL dump rounds any integer past 2^53.
- Cloudflare D1 alternatives: what you are actually coupled toD1 is SQLite, so your SQL is portable. The coupling is somewhere else: there is no wire protocol, the SQL export rounds any integer past 2^53, and you cannot get the file underneath. Here is an honest inventory, the alternatives, and when staying on D1 is the right call.