#8001·mikro-orm

Execute queries with bind parameters instead of inlining values into the SQL (v7 follow-up to #6829, with benchmarks)

Author: drudolfCreated Jul 18, 2026Updated Jul 22, 2026

Is your feature request related to a problem? Please describe.

In v7, AbstractSqlConnection.execute() still interpolates all parameters into the SQL text and executes with an empty bind array, even though the kysely foundation carries parameters natively:

typescript
// packages/sql/src/AbstractSqlConnection.ts (7.1.5 dist lines 195–222)
const formatted = this.platform.formatQuery(query, params);
// ...
const compiled = CompiledQuery.raw(q.formatted);   // parameters: []

Every executed statement therefore has a unique text (different literal values), which defeats everything keyed on statement identity downstream:

  • server-side prepared statements / plan reuse on PostgreSQL (and driver-side statement caches — this is where I ran into it, benchmarking MikroORM over a pg.Pool-compatible driver with a statement cache: it can never engage, because no query text ever repeats),
  • pg_stat_statements-style observability (every execution is a distinct entry pre-normalization),
  • any proxy/pooler feature keyed on parameterized statements.

I'm aware of #6829, where this was discussed for the knex era and described as deliberate (sqlite's low parameter limit, "performance reasons"), with the note that v7 would be the place to experiment and that the fix wouldn't be tricky there. This is that experiment, with numbers.

Describe the solution you'd like

Compile the existing ? placeholders to the dialect's bind placeholders and pass q.params through — CompiledQuery.raw(sql, parameters) already accepts them. I patched the 7.1.5 dist with a compile step that mirrors Platform.formatQuery's walk exactly (?? identifiers stay inlined — they can't be bound; \? stays a literal ?; adjacent-? semantics preserved) but emits $1..$n and collects values:

typescript
compileParameterized(sql, params) {
  if (params.length === 0) return CompiledQuery.raw(sql);
  // formatQuery's exact walk, emitting `$${++n}` + values.push(...) for `?`,
  // this.platform.quoteIdentifier(...) for `??`, literal `?` for `\?`
  return CompiledQuery.raw(ret, values);
}

Measured on the ORM benchmark harness of prisma-pglite-bridge (5 ops × 300 iterations × 3 repeats, spread-checked medians), MikroORM 7.1.5, two drivers:

Over a pg.Pool-compatible driver (kysely PostgresDialect via driverOptions), p50:

op inlined parameterized delta
single insert 0.28ms 0.24ms −14%
select where 0.45ms 0.38ms −16%
tx (r+w) 1.12ms 0.90ms −20%
findMany 1.58ms 1.52ms −4%
join 3.58ms 3.40ms −5%

p99 improves more (−25% insert, −33% where, −30% tx). And notably, the official @mikro-orm/pglite driver also gets faster with the same patch (insert 0.50→0.41ms p50, tx 1.94→1.50ms) — the win isn't specific to one driver, and on a real PostgreSQL server, bind parameters additionally unlock server-side plan reuse that an embedded benchmark can't show.

The concerns from #6829, addressed:

  • Parameter-count limits: real — PostgreSQL caps binds at 65535, sqlite defaults far lower. A guard that falls back to the current formatted execution when params.length exceeds a per-dialect threshold keeps huge batch inserts working exactly as today, while the overwhelmingly common small-arity queries get parameterized.
  • Type fidelity: values reach the driver as JS values instead of pre-rendered literals, so the serialization decisions quoteValue makes today (e.g. JSON-tagged embeddables → stringify) need to move into a value-mapping pass for the bind array. Drivers handle Date/Buffer natively.
  • Logging: getSql/logQuery can keep using q.formatted — human-readable logs, parameterized execution. (This would also give #6499 a natural path.)
  • stream() inlines the same way and would want the same treatment.

Describe alternatives you've considered

  • Leaving it as is: every consumer keyed on statement identity stays permanently cold; observability tooling sees unique texts forever.
  • Driver-side auto-parameterization (extracting literals back out of the formatted SQL): unsound in general — this really belongs where the parameters still exist.
  • Dialect-gating: enabling bind-parameter execution for postgres only (where the win is largest and the limit is highest) would sidestep the sqlite parameter-limit concern entirely, at the cost of divergent execution paths.

Additional context

MikroORM 7.1.5, Node 24, benchmarked on macOS (arm64) against PGlite-backed drivers; correctness verified by the harness's value/transaction checks before timing, and by inspecting pg_prepared_statements (statement cache engages, K=2 promotion, with the patch; provably never engages without it). Happy to share the full dist patch and the benchmark setup.