#1210·postgres

sql.begin() always throws UNSAFE_TRANSACTION when max_pipeline is 0

Author: reinierlakhanCreated Sep 2, 2026Updated Sep 2, 2026

Version: 3.4.9 (also current master, 411429e) Node: v26.8.1, plain PostgreSQL 16 (no pooler involved in the repro)

With the connection option max_pipeline: 0, every sql.begin() rejects with UNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1, even though the transaction is being run through sql.begin.

Reproduction

javascript
import postgres from 'postgres'
const url = process.env.DATABASE_URL
for (const [label, opts] of [['default', {}], ['max_pipeline: 1', { max_pipeline: 1 }], ['max_pipeline: 0', { max_pipeline: 0 }]]) {
  const sql = postgres(url, { max: 4, ...opts })
  try { const r = await sql.begin(tx => tx`select 1 as ok`); console.log(label, 'OK', r) }
  catch (e) { console.log(label, 'FAIL', e.code, e.message) }
  finally { await sql.end({ timeout: 2 }) }
}

Output on 3.4.9 / master:

default OK Result(1) [ { ok: 1 } ]
max_pipeline: 1 OK Result(1) [ { ok: 1 } ]
max_pipeline: 0 FAIL UNSAFE_TRANSACTION UNSAFE_TRANSACTION: Only use sql.begin, sql.reserved or max: 1

Cause

execute(q) in src/connection.js returns a single && chain:

javascript
      build(q)
      return write(toBuffer(q))
        && !q.describeFirst
        && !q.cursorFn
        && sent.length < max_pipeline
        && (!q.options.onexecute || q.options.onexecute(connection))

begin() in src/index.js sends BEGIN via sql.unsafe('begin ...', [], { onexecute }) and relies on that onexecute hook to capture the connection and move(c, reserved) / set c.reserved. Because the hook sits after sent.length < max_pipeline in the same chain, max_pipeline: 0 (0 < 0 is false) short-circuits before it, so the hook never runs. The connection is never reserved, and when the BEGIN's CommandComplete arrives the guard

javascript
    if (result.command === 'BEGIN' && max !== 1 && !connection.reserved)
      return errored(Errors.generic('UNSAFE_TRANSACTION', 'Only use sql.begin, sql.reserved or max: 1'))

fires. One expression is doing two unrelated jobs: "may the pool pipeline another query onto this connection" and "may the reservation hook run".

Why max_pipeline: 0

Behind a transaction-mode connection pooler (e.g. Supavisor/PgBouncer) a second query pipelined onto the socket before the first is answered is not handled correctly, so disabling pipelining entirely is the natural setting there. max_pipeline: 1 still lets one extra query be queued on the socket.

Expected / actual

  • Expected: sql.begin() works with max_pipeline: 0 exactly as with any other value; the option should only affect how many queries are queued on a connection.
  • Actual: every sql.begin() rejects with UNSAFE_TRANSACTION.

A PR with a fix and tests follows.