Ask
137

d1 insert loop dies around 1000 rows with too many API requests by single worker invocation Limits & Errors

CSV import Worker on the paid plan. Files are anywhere from 200 to 3,000 rows. The loop is the obvious thing:

for (const r of rows) {
  await env.DB.prepare("INSERT INTO items (sku, name, price) VALUES (?, ?, ?)")
    .bind(r.sku, r.name, r.price).run()
}

Small files import fine. Anything over about a thousand rows and I get Error 1015: Too many API requests by single worker invocation partway through, with some rows already committed. Is there a per-request query cap I have missed somewhere?

10 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @deploy_friday_ok · 2d ago · 3 replies

    Every D1 query counts as a subrequest, and subrequests are capped per Worker invocation — 1,000 on paid, 50 on free. Your loop spends one per row, so it dies at row 1,000 with beautiful consistency.

    Use batch():

    const stmt = env.DB.prepare(
      "INSERT INTO items (sku, name, price) VALUES (?, ?, ?)"
    )
    for (const chunk of chunked(rows, 100)) {
      await env.DB.batch(chunk.map(r => stmt.bind(r.sku, r.name, r.price)))
    }
    

    One batch() call is one subrequest regardless of how many statements are in it, and it runs as an implicit transaction. 3,000 rows becomes 30 subrequests instead of 3,000, and it will also be about an order of magnitude faster because you stop paying a round trip per row.

    Past roughly 10,000 rows you will run into CPU time instead, and the answer there is a Queue consumer processing a few hundred rows per message.

    92
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @ridgeline_rowan · 3d ago

      Chunks of 100 took a 2,400 row import from timing out to 1.9 seconds. I had read "subrequest" as meaning outbound fetch only and never connected it to database calls.

      29
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @orm_tamsin · 4d ago

      If the column list is fixed you can go further and build one statement with 100 value tuples — one subrequest and one parse instead of 100 prepared bindings. Mind the SQLite bound parameter ceiling of 999 while you do it: with three columns that is 333 rows a statement, with six it is 166.

      18
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @visa_run_val · 2d ago · 2 replies

    Worth knowing that error is not D1-specific — outbound fetch() calls draw on the same budget, so a Worker that calls an API per row hits it identically. Add a counter around whatever you think is expensive so the next time this happens you know which category burned it.

    And the budget is per invocation, so pushing work into ctx.waitUntil() does not hand you a fresh one. Splitting across Queue messages does, because each message is its own invocation.

    51
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @ridgeline_rowan · 4d ago

      That waitUntil detail would have cost me a day, it was going to be my next attempt.

      12
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @first_repo_finn · 2d ago

    The shape that holds up long term for imports: upload to R2, drop a message on a Queue, consumer reads the object and processes it in chunks, D1 gets batched writes. Right now a user's browser is sitting on an open connection while you import 3,000 rows, and that is its own bug waiting to be filed.

    34
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @boundaries_bo · 4d ago · 2 replies

    The thing that will bite you next: batch() is atomic, so one bad row rolls back all 100 of its neighbours. Combine that with a retry and you get duplicates from the chunks that already succeeded.

    Put a unique constraint on your natural key and write INSERT ... ON CONFLICT DO NOTHING. Then re-running the whole import is free and you stop caring where it died.

    22
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @deploy_friday_ok · 4d ago

      Idempotent imports are the difference between an import feature and an import incident. Also log the chunk index somewhere so a partial failure tells you what to resume rather than making you diff the table.

      14
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @boundaries_bo · 4d ago · 2 replies

    Wrap the whole loop in BEGIN / COMMIT and it becomes a single transaction, which should count as one subrequest.

    11
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @visa_run_val · 4d ago

      D1 does not support explicit BEGIN/COMMIT through the client API — batch() is the sanctioned way to get a transaction. And even if it did, each statement you send as a separate call is still its own subrequest no matter what it says inside.

      10
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report