Skip to content

fix(drizzle): avoid double-binding columns in SQLite upserts on D1 - #18239

Open
josep2 wants to merge 3 commits into
payloadcms:3.xfrom
josep2:fix/sqlite-upsert-bound-params
Open

josep2 wants to merge 3 commits into
payloadcms:3.xfrom
josep2:fix/sqlite-upsert-bound-params

Conversation

@josep2

@josep2 josep2 commented Sep 21, 2026

Copy link
Copy Markdown

What?

On @payloadcms/db-d1-sqlite, saving a document in any collection whose main table has more than 50 columns fails with D1_ERROR: too many SQL variables. The SQLite adapter's upsertRow update path issues INSERT ... ON CONFLICT (id) DO UPDATE SET, which binds every column twice - once in VALUES and once in the SET clause - so a single-row upsert on a 51-column table binds 102 parameters and overflows D1's limit of 100 bound parameters per statement. Because D1 has no transactions, the failure also lands after related rows (e.g. versions) have already been written.

Refs #14766 (the >50-column upsert case; tables wider than 100 columns still need update batching, as discussed there).

How?

In packages/drizzle/src/sqlite/insert.ts, when the adapter sets limitedBoundParameters (the D1 adapter), ON CONFLICT ... DO UPDATE SET entries that correspond to inserted columns are rewritten to reference SQLite's excluded table ("col" = excluded."col") instead of binding the value a second time. Each column is then bound exactly once (in VALUES), halving the parameter count while keeping the upsert semantics - including the DO UPDATE ... WHERE clause - identical. Values that are already SQL fragments, and columns not present in the inserted row(s), are left bound as-is.

Measured with drizzle-orm 0.45.2: a 51-column upsert goes from 102 to 51 bound parameters, and the generated UPDATE results are byte-identical in all branches (update, insert-when-missing, WHERE no-op).

Tests

New packages/drizzle/src/sqlite/insert.spec.ts simulates the D1 limit by wrapping the libsql client's execute to throw above 100 bound parameters, on a 51-column table:

  • update via upsert on a >50-column table stays under the limit and persists
  • upsert still inserts when the target row does not exist yet
  • DO UPDATE ... WHERE still no-ops when the condition does not match
  • multi-row batched upserts on wide tables stay under the limit

All 4 tests fail with too many SQL variables before the fix and pass after. The full packages/drizzle unit suite passes (113 tests). The equivalent mitigation for this root cause has been running in production on a D1-backed Payload site.

Notes

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant