Skip to content

Bug: Session(&gorm.Session{PrepareStmt:false}) silently inherits parent PrepareStmt:true — cannot downgrade for AutoMigrate / DDL flows #7765

Description

@JNZader

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#851TestBug_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:

  1. Uses PrepareStmt: true in gorm.Config (recommended for production perf).
  2. Runs AutoMigrate (or any DDL that triggers cached-plan invalidation 0A000) at runtime.
  3. 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

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