GORM Playground Link
go-gorm/playground#851
Description
(*gorm.DB).Session(&gorm.Session{PrepareStmt: false}) is asymmetric: it can upgrade a non-prepared parent into a prepared session, but it cannot downgrade a prepared parent back to non-prepared. The false value is indistinguishable from the zero value, so the session inherits the parent's PrepareStmt: true ConnPool.
This is the API gap that makes the postgres-driver AlterColumn panic (filed separately) hard to work around cleanly: consumers cannot say "run this DDL without PrepareStmt", they must hand-unwrap *gorm.PreparedStmtDB (and *gorm.PreparedStmtTX if they're already inside a transaction) and swap ConnPool themselves — fragile against any internal refactor and undiscoverable for new users.
Prior reports — three closed-without-fix issues since 2020
This same defect has been reported three times before and closed without a code fix:
Each closure miscategorized what is actually a correctness defect in Session(): an explicit boolean argument is silently dropped. Filing again with a deterministic version-pinned repro, an explicit suggested fix that handles both *PreparedStmtDB and *PreparedStmtTX, and source-line citations against the v1.31.1 tag.
Source location
https://github.com/go-gorm/gorm/blob/v1.31.1/gorm.go#L280-L314
if config.PrepareStmt { // <-- only handles `true`
var preparedStmt *PreparedStmtDB
if v, ok := db.cacheStore.Load(preparedStmtDBKey); ok {
preparedStmt = v.(*PreparedStmtDB)
} else {
preparedStmt = NewPreparedStmtDB(db.ConnPool, db.PrepareStmtMaxSize, db.PrepareStmtTTL)
db.cacheStore.Store(preparedStmtDBKey, preparedStmt)
}
switch t := tx.Statement.ConnPool.(type) {
case Tx:
tx.Statement.ConnPool = &PreparedStmtTX{Tx: t, PreparedStmtDB: preparedStmt}
default:
tx.Statement.ConnPool = &PreparedStmtDB{ConnPool: db.Config.ConnPool, ...}
}
txConfig.ConnPool = tx.Statement.ConnPool
txConfig.PrepareStmt = true
}
// no else branch for explicit `false` — parent's prepared ConnPool stays
Because bool zero-value is false, there is no way to distinguish "caller wants PrepareStmt = false" from "caller didn't set this field" without a tri-state (*bool or a separate DisablePrepareStmt bool).
Reproduction
Playground: go-gorm/playground#851 — TestBug_SessionPrepareStmtCannotDowngrade builds a parent with PrepareStmt: true, asks for a child with PrepareStmt: false, and asserts the child's Statement.ConnPool type. Driver-agnostic — observable from Statement.ConnPool type alone, no SQL needs to be issued.
parent, _ := gorm.Open(postgres.Open(dsn), &gorm.Config{PrepareStmt: true})
// Try to get a non-prepared session for a one-off DDL:
ddlSess := parent.Session(&gorm.Session{PrepareStmt: false})
// ddlSess.Statement.ConnPool is STILL *PreparedStmtDB
// — the `false` was a no-op.
Observed:
parent ConnPool type: *gorm.PreparedStmtDB
child ConnPool type: *gorm.PreparedStmtDB
Suggested fix
Two options. Option A is API-additive and backwards compatible.
Option A (recommended): add DisablePrepareStmt bool to Session
// gorm.io/gorm/gorm.go (Session struct, declared at line 113 in v1.31.1)
type Session struct {
...
PrepareStmt bool
DisablePrepareStmt bool // explicit downgrade flag (NEW)
...
}
In gorm.go::Session(), after the existing if config.PrepareStmt block:
} else if config.DisablePrepareStmt {
switch t := tx.Statement.ConnPool.(type) {
case *PreparedStmtTX:
tx.Statement.ConnPool = t.Tx // unwrap to plain Tx
case *PreparedStmtDB:
tx.Statement.ConnPool = t.ConnPool // unwrap to underlying pool
}
// If neither type matched, the parent wasn't prepared — DisablePrepareStmt is a no-op.
txConfig.ConnPool = tx.Statement.ConnPool
txConfig.PrepareStmt = false
}
The Statement is already cloned earlier in Session() when any of Context | PrepareStmt | SkipHooks is set. Extend that condition to include DisablePrepareStmt:
if config.Context != nil || config.PrepareStmt || config.DisablePrepareStmt || config.SkipHooks {
tx.Statement = tx.Statement.clone()
tx.Statement.DB = tx
}
Option B: convert PrepareStmt to *bool
Tri-state pointer makes "explicit false" representable. Breaking change to the Session struct surface; not recommended unless aggregated with other v2 changes.
Why this matters
The blast radius is anyone who:
- Uses
PrepareStmt: true in gorm.Config (recommended for production perf).
- Runs
AutoMigrate (or any DDL that triggers cached-plan invalidation 0A000) at runtime.
- Tries to follow the natural API to scope DDL to a non-prepared session.
Related but distinct
#5737, #4588, #7255 cover the cached-plan invalidation family (pgx prepared-plan errors after DDL — different mechanism). Cite them as evidence the prepared-statement subsystem is broken in production, but this issue is structurally distinct: it's about Session config inheritance, not invalidation handling.
Environment
- Go: 1.22.2
- gorm.io/gorm: v1.31.1
- gorm.io/driver/postgres: v1.6.0
- PostgreSQL: 16.4-alpine
- OS: Linux 6.18
GORM Playground Link
go-gorm/playground#851
Description
(*gorm.DB).Session(&gorm.Session{PrepareStmt: false})is asymmetric: it can upgrade a non-prepared parent into a prepared session, but it cannot downgrade a prepared parent back to non-prepared. Thefalsevalue is indistinguishable from the zero value, so the session inherits the parent'sPrepareStmt: trueConnPool.This is the API gap that makes the postgres-driver
AlterColumnpanic (filed separately) hard to work around cleanly: consumers cannot say "run this DDL withoutPrepareStmt", they must hand-unwrap*gorm.PreparedStmtDB(and*gorm.PreparedStmtTXif they're already inside a transaction) and swapConnPoolthemselves — fragile against any internal refactor and undiscoverable for new users.Prior reports — three closed-without-fix issues since 2020
This same defect has been reported three times before and closed without a code fix:
stale).PrepareStmtfeature can be disabled in session while it enabled in globally mode #4991 — "PrepareStmtfeature can be disabled in session while it enabled in globally mode" (closed 2022-01-17 astype:feature_request).PrepareStmt: falseis invalid #5026 — "db.Session setPrepareStmt: falseis invalid" (closed 2022-01-29 astype:question).Each closure miscategorized what is actually a correctness defect in
Session(): an explicit boolean argument is silently dropped. Filing again with a deterministic version-pinned repro, an explicit suggested fix that handles both*PreparedStmtDBand*PreparedStmtTX, and source-line citations against the v1.31.1 tag.Source location
https://github.com/go-gorm/gorm/blob/v1.31.1/gorm.go#L280-L314
Because
boolzero-value isfalse, there is no way to distinguish "caller wantsPrepareStmt = false" from "caller didn't set this field" without a tri-state (*boolor a separateDisablePrepareStmt bool).Reproduction
Playground: go-gorm/playground#851 —
TestBug_SessionPrepareStmtCannotDowngradebuilds a parent withPrepareStmt: true, asks for a child withPrepareStmt: false, and asserts the child'sStatement.ConnPooltype. Driver-agnostic — observable fromStatement.ConnPooltype alone, no SQL needs to be issued.Observed:
Suggested fix
Two options. Option A is API-additive and backwards compatible.
Option A (recommended): add
DisablePrepareStmt booltoSessionIn
gorm.go::Session(), after the existingif config.PrepareStmtblock:The Statement is already cloned earlier in
Session()when any ofContext | PrepareStmt | SkipHooksis set. Extend that condition to includeDisablePrepareStmt:Option B: convert
PrepareStmtto*boolTri-state pointer makes "explicit false" representable. Breaking change to the
Sessionstruct surface; not recommended unless aggregated with other v2 changes.Why this matters
The blast radius is anyone who:
PrepareStmt: trueingorm.Config(recommended for production perf).AutoMigrate(or any DDL that triggers cached-plan invalidation0A000) at runtime.Related but distinct
#5737, #4588, #7255 cover the cached-plan invalidation family (pgx prepared-plan errors after DDL — different mechanism). Cite them as evidence the prepared-statement subsystem is broken in production, but this issue is structurally distinct: it's about
Sessionconfig inheritance, not invalidation handling.Environment