D1_ERROR: too many SQL variables? Cara Fix Batas 100 Bind
D1_ERROR: too many SQL variables artinya satu statement nge-bind lebih dari 100 nilai. Ini hitungannya, fix dengan chunking, dan hasil tes db.batch lokal gue.

D1_ERROR: too many SQL variables at offset 241: SQLITE_ERROR
Itu error yang dilempar Cloudflare D1, database serverless berbasis SQLite, kalau satu statement nge-bind lebih dari 100 nilai. Yang dihitung itu semua placeholder ?, bukan cuma yang ada di dalam list IN (...) lo. Cara fix-nya: pecah list jadi beberapa chunk yang masih nyisain tempat buat bind tetap, atau kirim chunk-chunk itu sekaligus pakai db.batch().
Di bawah ini ada hitungan yang bikin gue kecolongan, kode chunking yang udah gue deploy, dan test double yang sekarang gagal persis kayak D1.
Cara Fix Cepat: Chunk List IN-nya
Potong list jadi beberapa grup, bind tiap grup di statement sendiri, lalu gabungin barisnya. Ini kode yang gue pakai, namanya udah diganti:
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);
}Statement-nya punya 5 bind tetap, empat batas waktu dan satu tenant id, lalu tiap id di chunk nambah satu bind.
Gue pakai 50 id per chunk, jadi tiap statement nge-bind 55 nilai. Dengan 5 bind tetap, chunk sampai 95 id masih muat.
Kenapa D1 Bilang Too Many SQL Variables?
D1 cuma ngizinin maksimal 100 bound parameter per query, menurut halaman limits D1. Query dengan list IN (...) makai satu bind per id, dan setiap ? lain di statement itu makan satu bind lagi.
Beberapa panduan pihak ketiga nyalahin limit SQLite sebesar 999 variabel buat error ini. Untuk D1 angkanya 100. Dokumentasi Cloudflare bilang begitu, dan tes lokal gue setuju: 100 nilai jalan dan 101 gagal.
Query export gue punya 100 id di list plus 5 bind tetap, empat batas waktu dan satu tenant id. Totalnya 105 nilai, kelebihan lima. Satu halaman penuh isi 100 baris nggak mungkin muat, dan dengan hitungan yang sama, halaman 95 baris atau kurang bakal muat.
Gue reproduksi ini pada 18 Oktober 2026 secara lokal pakai wrangler@4.151.1 dev --local di Worker sekali pakai. Ini hasil tiap statement:
| Statement | Nilai yang di-bind | Hasil |
|---|---|---|
| List IN isi 100 id, tanpa bind lain | 100 | Jalan |
| List IN isi 101 id, tanpa bind lain | 101 | D1_ERROR: too many SQL variables at offset 241: SQLITE_ERROR |
| 95 id plus 5 bind tetap | 100 | Jalan |
| 100 id plus 5 bind tetap | 105 | D1_ERROR: too many SQL variables at offset 347: SQLITE_ERROR |
2 chunk isi 50 id plus 5 bind tetap, dijalanin pakai Promise.all | 55 per statement | Dua-duanya jalan |
Angka offset di pesan itu adalah posisi di dalam teks SQL, jadi bakal beda tergantung query. Jangan dicocokin ke 241. Gue nggak tes ke database production remote, jadi run lokal ini cuma nunjukin teks error dan batasnya, sedangkan angka limit-nya sendiri berasal dari dokumentasi Cloudflare.
Halaman limits Cloudflare juga ngebatesin satu statement maksimal 100.000 byte. List IN yang panjang bisa nabrak itu juga, tapi batas 100 bind kena duluan.
Cara Fix 2: Kirim Chunk Pakai db.batch
db.batch() ngirim beberapa statement dalam satu panggilan. Dokumentasi D1 bilang ini ngurangin round trip jaringan, statement-nya jalan satu per satu dan nggak bersamaan, dan batch itu adalah SQL transaction. Kalau satu statement gagal, seluruh urutannya di-rollback.
Batas 100 bind berlaku per statement, bukan buat total batch. Di tes lokal gue, batch isi tiga statement dengan 100 bind masing-masing, total 300, jalan tanpa error. Sebuah tulisan tahun 2023 soal error ini sampai di kesimpulan yang sama untuk bulk insert, di mana satu INSERT per record di dalam db.batch() ngehindarin 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);Hasilnya balik dengan urutan yang sama kayak statement-nya.
Chunk Pakai Promise.all atau db.batch?
Dua-duanya ngehindarin error. Bedanya ada di cara jalan dan cara gagalnya.
Promise.all | db.batch() | |
|---|---|---|
| Round trip | Satu per chunk | Satu panggilan |
| Eksekusi | Statement bisa jalan paralel | Statement jalan berurutan, satu per satu |
| Kalau satu gagal | Yang lain tetap selesai, lo dapat rejection | Seluruh urutan di-rollback |
| Cocok buat | Lookup read-only yang saling independen | Write yang harus sukses semua |
Gue pilih Promise.all karena lookup gue cuma baca data dan nggak saling bergantung. Gue nggak benchmark dia lawan db.batch(), jadi gue nggak bisa bilang mana yang lebih cepat buat ukuran list lo.
Tiap chunk itu query terpisah. Cloudflare nyebut 1.000 query per invocation Worker di Workers Paid dan 50 di Free, jadi list yang panjang banget bisa nabrak limit itu sebelum nabrak yang lain.
Gimana Gue Ketemu Error Ini
Jalur yang gagal itu export CSV di dashboard multi-tenant. Tiap halaman export isi 100 baris, dan tiap baris butuh total impression dan klik dari tabel kedua, diambil pakai satu query IN (...).
API-nya nangkap error itu lalu balikin HTTP 500 polos dengan pesan generik. Pesan asli D1 nggak pernah sampai ke layar, jadi tombol export-nya ya gagal gitu aja. Yang bikin ketahuan itu angkanya. Satu halaman penuh itu 100 id, dan 100 id plus 5 bind tetap jadi 105.
Kalau lo ketemu 500 yang samar di sekitar query yang nerima list, log error.cause dulu sebelum ngapa-ngapain. Di run lokal gue, isi cause sama dengan teks message, tanpa prefix D1_ERROR:.
Biar Nggak Kejadian Lagi: Bikin Test Double Gagal Kayak D1
Unit test gue pakai fake D1 in-memory yang nyatet tiap statement. Fake itu udah ngelempar error kalau jumlah placeholder ? nggak sama dengan jumlah nilai yang di-bind. Gue tambahin guard kedua buat limit 100, supaya test gagal dengan cara yang sama kayak production. Kalau lo lagi menimbang apakah setup test kayak gini sepadan sama usahanya, gue pernah bahas di Kapan Unit Testing Jadi Penting Banget: Perjalanan Seorang Developer.
Pesan versi gue itu D1 too many SQL variables, yang bukan teks yang dikembaliin D1 asli. D1 asli nambahin prefix D1_ERROR: dan offset. Kalau gue tulis guard-nya lagi, gue bakal niru bentuk aslinya:
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})`,
);
}Regression test-nya nge-seed 100 device lalu ngecek empat hal:
- Lookup-nya kepecah jadi lebih dari satu statement.
- Tiap statement nge-bind 100 nilai atau kurang.
- Ke-100 id-nya semua ke-query.
- Hasil gabungannya mendarat di baris yang benar.
Hitungan yang sama berlaku buat bulk insert. Tiap baris nge-bind satu nilai per kolom, jadi tabel dengan 4 kolom cuma muat maksimal 25 baris dalam satu statement.
Also Read: Cloudflare waitUntil: Audit Log Tanpa Bikin Response Lambat
Yang Dicek Selanjutnya
Hitung semua ? di statement, bukan cuma yang ada di list. Kurangin 100 dengan jumlah bind tetap, dan pakai hasilnya sebagai batas atas chunk lo. Kalau kerjaannya read-only dan independen, chunk pakai Promise.all. Kalau chunk-nya write yang harus sukses bareng, pakai db.batch(). Apa pun pilihannya, tambahin test double yang ngelempar error di atas 100, supaya query berikutnya yang tumbuh melewati limit gagal di CI dan bukan di tombol export.


