D1_ERROR: too many SQL variables? Fix the 100-Bind Limit
D1_ERROR: too many SQL variables means one statement binds over 100 values. See the 100 plus 5 arithmetic, a chunking fix, and a db.batch test I ran locally.

D1_ERROR: too many SQL variables at offset 241: SQLITE_ERROR
That is the error Cloudflare D1, the SQLite-based serverless database, throws when a single statement binds more than 100 values. Every ? placeholder counts, not only the ones inside your IN (...) list. The fix is to split the list into chunks that leave room for the fixed binds, or to send the chunks together with db.batch().
Below is the arithmetic that caught me out, the chunking code I shipped, and a test double that now fails the same way D1 does.
Quick Fix: Chunk the IN List
Cut the list into groups, bind each group in its own statement, then merge the rows. This is the code I use, with the names changed:
const ID_CHUNK_SIZE = 50;
const chunks: string[][] = [];
for (let i = 0; i < ids.length; i += ID_CHUNK_SIZE) {
chunks.push(ids.slice(i, i + ID_CHUNK_SIZE));
}
const chunkResults = await Promise.all(
chunks.map((chunk) =>
db
.prepare(
`SELECT device_id,
SUM(event_type = 'impression' AND created_at >= ? AND created_at < ?) AS impressions,
SUM(event_type = 'click' AND created_at >= ? AND created_at < ?) AS clicks
FROM events
WHERE tenant = ?
AND device_id IN (${chunk.map(() => "?").join(",")})
GROUP BY device_id`,
)
.bind(fromMs, toMs, fromMs, toMs, tenant, ...chunk)
.all<{ device_id: string; impressions: number; clicks: number }>(),
),
);
const byDevice = new Map<string, { impressions: number; clicks: number }>();
for (const { results } of chunkResults) {
for (const row of results) byDevice.set(row.device_id, row);
}The statement has 5 fixed binds, four time bounds and one tenant id, and the chunk adds one per id.
I used 50 ids per chunk, so each statement binds 55 values. With 5 fixed binds, any chunk up to 95 ids fits.
Why Does D1 Say Too Many SQL Variables?
D1 allows at most 100 bound parameters per query, according to the D1 limits page. A query with an IN (...) list uses one bind per id, and every other ? in the statement uses one more.
Some third-party guides blame a SQLite limit of 999 variables for this error. For D1 the number is 100. The Cloudflare docs say so, and my local test agrees: 100 values worked and 101 failed.
My export query had 100 ids in the list and 5 fixed binds, four time bounds and one tenant id. That is 105 bound values, five over the limit. A full page of 100 rows could never fit, and by the same arithmetic a page of 95 rows or fewer would.
I reproduced this on October 18, 2026 locally with [email protected] dev --local in a throwaway Worker. This is what each statement did:
| Statement | Bound values | Result |
|---|---|---|
| IN list of 100 ids, nothing else | 100 | Works |
| IN list of 101 ids, nothing else | 101 | D1_ERROR: too many SQL variables at offset 241: SQLITE_ERROR |
| 95 ids plus 5 fixed binds | 100 | Works |
| 100 ids plus 5 fixed binds | 105 | D1_ERROR: too many SQL variables at offset 347: SQLITE_ERROR |
2 chunks of 50 ids plus 5 fixed binds, run with Promise.all | 55 each | Both work |
The offset in the message is a position inside the SQL text, so it changes with the query. Do not match on 241. I did not test against a remote production database, so the local run shows the error text and the boundary, and the limit itself comes from the Cloudflare docs.
Cloudflare’s limits page also caps a statement at 100,000 bytes. A long IN list can hit that too, but the 100 bind limit comes first.
Fix 2: Send the Chunks With db.batch
db.batch() sends several statements in one call. The D1 docs say this reduces network round trips, that the statements run one after another and not at the same time, and that the batch is a SQL transaction. If one statement fails, the whole sequence rolls back.
The 100 bind limit applies to each statement and not to the batch total. In my local test, a batch of three statements with 100 binds each, 300 in total, ran without an error. A 2023 write-up on this error reached the same conclusion for bulk inserts, where one INSERT per record inside db.batch() avoids the limit.
const statements = chunks.map((chunk) =>
db
.prepare(`SELECT device_id, COUNT(*) AS events FROM events WHERE tenant = ? AND device_id IN (${chunk.map(() => "?").join(",")}) GROUP BY device_id`)
.bind(tenant, ...chunk),
);
const results = await db.batch<{ device_id: string; events: number }>(statements);The results come back in the same order as the statements.
Chunk With Promise.all or db.batch?
Both avoid the error. They differ in how they run and how they fail.
Promise.all | db.batch() | |
|---|---|---|
| Round trips | One per chunk | One call |
| Execution | Statements can run in parallel | Statements run in order, one at a time |
| If one fails | The others still finish, you get a rejection | The whole sequence rolls back |
| Good for | Independent read-only lookups | Writes that must all succeed together |
I chose Promise.all because my lookups only read data and do not depend on each other. I did not benchmark it against db.batch(), so I cannot tell you which is faster for your list size.
Each chunk is a separate query. Cloudflare lists 1,000 queries per Worker invocation on Workers Paid and 50 on Free, so a very long list can run into that limit before it runs into anything else.
How I Ran Into This
The failing path was a CSV export on a multi-tenant dashboard. Each export page held 100 rows, and every row needed impression and click totals from a second table, fetched with one IN (...) query.
The API caught the error and returned a bare HTTP 500 with a generic message. The real D1 message never reached the screen, so the export button just failed. What gave it away was the number. A full page is 100 ids, and 100 ids plus 5 fixed binds is 105.
If you see a vague 500 around a query that takes a list, log error.cause before you do anything else. In my local run the cause carried the same text as the message, with the D1_ERROR: prefix removed.
Stop It Coming Back: Make the Test Double Fail Like D1
My unit tests use an in-memory fake D1 that records each statement. It already threw when the number of ? placeholders did not match the number of bound values. I added a second guard for the 100 limit, so a test fails the way production does. If you are weighing whether a test setup like this is worth the effort, I wrote about that in When Unit Testing Becomes Essential: A Developer’s Journey.
My version of the message was D1 too many SQL variables, which is not what D1 returns. Real D1 prefixes D1_ERROR: and adds the offset. If I wrote the guard again, I would copy the real shape:
const MAX_BOUND_PARAMS = 100;
if (params.length > MAX_BOUND_PARAMS) {
throw new Error(
`D1_ERROR: too many SQL variables (${params.length} bound values, max ${MAX_BOUND_PARAMS})`,
);
}The regression test seeds 100 devices and asserts four things:
- The lookup splits into more than one statement.
- Every statement binds 100 values or fewer.
- All 100 ids are queried.
- The merged results land on the right rows.
The same arithmetic applies to bulk inserts. Each row binds one value per column, so a 4 column table fits at most 25 rows in one statement.
Also Read: Cloudflare waitUntil Logging: Audit Logs Without Latency
What to Check Next
Count every ? in the statement, not only the list. Subtract the fixed binds from 100, and use that as your chunk ceiling. If the work is read-only and independent, chunk with Promise.all. If the chunks are writes that must succeed together, use db.batch(). Either way, add a test double that throws over 100, so the next query that grows past the limit fails in CI and not in an export.
Also Read: Astro 6 After Cloudflare: Why I Upgraded Now


