Summary
mapModel.Scan calls (*sql.Rows).ColumnTypes() from inside (*sql.Rows).Scan. As of Go 1.27 that is a guaranteed self-deadlock — not a race — when the driver implements the new driver.RowsColumnScanner interface. pgx v5.11.0 does.
Any query scanning into *map[string]any or *[]map[string]any hangs forever, with no timeout and no error, as soon as one column's driver value is []byte (jsonb, bytea, numeric).
Versions
- bun v1.2.18 (and master —
model_map.go is unchanged since 2025-08-09)
- github.com/jackc/pgx/v5 v5.11.0
- Go 1.27.1
Reproduction
sqldb, _ := sql.Open("pgx", dsn)
db := bun.NewDB(sqldb, pgdialect.New())
m := make(map[string]any)
_, err := db.NewSelect().ColumnExpr(`'{"k":1}'::jsonb AS a`).Exec(ctx, &m)
// never returns
Scalar columns (1 AS a, 'x'::text AS b) do not hang, because pgx returns them as int64/string rather than []byte, so mapModel.Scan takes the scanRaw early return and never reaches columnTypes().
Cause
Go 1.27 added driver.RowsColumnScanner. In database/sql, (*Rows).scanLocked now takes the driver-connection mutex and holds it across the whole per-column loop:
if rscan, ok := rs.rowsi.(driver.RowsColumnScanner); ok {
rs.dc.Lock()
defer rs.dc.Unlock()
for i, d := range dest {
scanCtx := driver.ScanContext(internal.NewScanContext(rs))
if err := rscan.ScanColumn(scanCtx, i, d); err != nil { ... }
}
return nil
}
pgx v5.11.0 implements that interface in stdlib/sql_go1.27.go (added in jackc/pgx#2542), and routes any sql.Scanner destination through sql.ConvertAssign, which calls dest.Scan(src).
bun's mapModel is an sql.Scanner and is passed as every destination. In model_map.go:
Scan (line 70) calls m.columnTypes() at line 75 when src is []byte
columnTypes (line 100) calls m.rows.ColumnTypes() at line 102
(*sql.Rows).ColumnTypes takes rs.dc.Lock() — the same non-reentrant sync.Mutex already held by scanLocked
Deadlock. Stack trace:
internal/sync.(*Mutex).lockSlow
database/sql.(*Rows).ColumnTypes sql.go:3232
github.com/uptrace/bun.(*mapModel).columnTypes model_map.go:102
github.com/uptrace/bun.(*mapModel).Scan model_map.go:75
database/sql.convertAssignRows convert.go:453
github.com/jackc/pgx/v5/stdlib.(*Rows).ScanColumn sql_go1.27.go:64
database/sql.(*Rows).scanLocked sql.go:3440
database/sql.(*Rows).Scan sql.go:3403
github.com/uptrace/bun.(*mapModel).ScanRows model_map.go:61
mapSliceModel embeds mapModel, so *[]map[string]any destinations are affected identically.
Note that pinning the go directive in go.mod back to 1.26 does not avoid this — the go1.27 toolchain is what satisfies pgx's //go:build go1.27 constraint, regardless of the module's language version.
Suggested fix
ScanRows already calls rows.Columns() before the scan loop. Resolving the column types in the same place, before rows.Scan(dest...), removes the re-entrant call entirely:
func (m *mapModel) ScanRows(ctx context.Context, rows *sql.Rows) (int, error) {
if !rows.Next() {
return 0, rows.Err()
}
columns, err := rows.Columns()
if err != nil {
return 0, err
}
columnTypes, err := rows.ColumnTypes() // hoisted out of Scan
if err != nil {
return 0, err
}
m.rows = rows
m.columns = columns
m._columnTypes = columnTypes
...
}
Same change in mapSliceModel.ScanRows, where it also moves the call out of the per-row loop. The cost is one ColumnTypes() call per query even when every column scans raw — cheap, and it replaces a lazy path that was already doing the same work on the first []byte column.
More generally, any sql.Scanner implementation in bun that calls back into the *sql.Rows it is being scanned from is now unsafe on Go 1.27; mapModel is the one I hit.
Context
Nothing in database/sql documents this constraint. Neither sql.Scanner, (*Rows).Scan, driver.RowsColumnScanner nor the Go 1.27 release notes say that a Scanner must not call back into the Rows, so this code was legal before 1.27 and silently became a hang. I could not find an existing issue for it in bun, pgx or golang/go.
Related but distinct: #1339 and golang/go#76465 cover the deadlock that happens when a Scanner panics under closemu. This one needs no panic.
Summary
mapModel.Scancalls(*sql.Rows).ColumnTypes()from inside(*sql.Rows).Scan. As of Go 1.27 that is a guaranteed self-deadlock — not a race — when the driver implements the newdriver.RowsColumnScannerinterface. pgx v5.11.0 does.Any query scanning into
*map[string]anyor*[]map[string]anyhangs forever, with no timeout and no error, as soon as one column's driver value is[]byte(jsonb, bytea, numeric).Versions
model_map.gois unchanged since 2025-08-09)Reproduction
Scalar columns (
1 AS a, 'x'::text AS b) do not hang, because pgx returns them asint64/stringrather than[]byte, somapModel.Scantakes thescanRawearly return and never reachescolumnTypes().Cause
Go 1.27 added
driver.RowsColumnScanner. Indatabase/sql,(*Rows).scanLockednow takes the driver-connection mutex and holds it across the whole per-column loop:pgx v5.11.0 implements that interface in
stdlib/sql_go1.27.go(added in jackc/pgx#2542), and routes anysql.Scannerdestination throughsql.ConvertAssign, which callsdest.Scan(src).bun's
mapModelis ansql.Scannerand is passed as every destination. Inmodel_map.go:Scan(line 70) callsm.columnTypes()at line 75 whensrcis[]bytecolumnTypes(line 100) callsm.rows.ColumnTypes()at line 102(*sql.Rows).ColumnTypestakesrs.dc.Lock()— the same non-reentrantsync.Mutexalready held byscanLockedDeadlock. Stack trace:
mapSliceModelembedsmapModel, so*[]map[string]anydestinations are affected identically.Note that pinning the
godirective in go.mod back to 1.26 does not avoid this — the go1.27 toolchain is what satisfies pgx's//go:build go1.27constraint, regardless of the module's language version.Suggested fix
ScanRowsalready callsrows.Columns()before the scan loop. Resolving the column types in the same place, beforerows.Scan(dest...), removes the re-entrant call entirely:Same change in
mapSliceModel.ScanRows, where it also moves the call out of the per-row loop. The cost is oneColumnTypes()call per query even when every column scans raw — cheap, and it replaces a lazy path that was already doing the same work on the first[]bytecolumn.More generally, any
sql.Scannerimplementation in bun that calls back into the*sql.Rowsit is being scanned from is now unsafe on Go 1.27;mapModelis the one I hit.Context
Nothing in
database/sqldocuments this constraint. Neithersql.Scanner,(*Rows).Scan,driver.RowsColumnScannernor the Go 1.27 release notes say that aScannermust not call back into theRows, so this code was legal before 1.27 and silently became a hang. I could not find an existing issue for it in bun, pgx or golang/go.Related but distinct: #1339 and golang/go#76465 cover the deadlock that happens when a
Scannerpanics underclosemu. This one needs no panic.