Skip to content

Scanning into map[string]any deadlocks forever on Go 1.27 with pgx v5.11 #1434

Description

@hotrush

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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions