#7832·gorm

Prepared statement leak: expired cache entry overwritten without Close

Author: sazaryCreated Jul 30, 2026Updated Aug 8, 2026
Labelstype:with reproduction steps

GORM Playground Link

https://github.com/go-gorm/playground/pull/855

Description

A long-lived service using gorm.Config{PrepareStmt: true} (and therefore a PrepareStmtTTL, default 24h) slowly accumulates *sql.Stmt values that are never closed. The count is not bounded by PrepareStmtMaxSize, and the statements are released only when the whole *sql.DB is closed — so on PostgreSQL/MySQL the server-side prepared statements stay allocated too. It leaks one statement per TTL cycle per hot query.

LRU.Get treats an expired entry as a miss but leaves it in c.items:

go
	if ent, ok = c.items[key]; ok {
		// Expired item check
		if time.Now().After(ent.ExpiresAt) {
			return value, false
		}

Because it is a miss, PreparedStmtDB.prepare falls through to Stmts.New, which caches the new statement via SetLRU.Add. Add finds the key still present and takes the existing-item branch, which overwrites the value without calling onEvict:

go
	// Check for existing item
	if ent, ok := c.items[key]; ok {
		c.evictList.MoveToFront(ent)
		c.removeFromBucket(ent) // remove the entry from its current bucket as expiresAt is renewed
		ent.Value = value
		ent.ExpiresAt = now.Add(c.ttl)
		c.addToBucket(ent)
		return false
	}

onEvict is the only place cached statements are ever closed — internal/stmt_store/stmt_store.go#L108-L112:

go
	onEvicted := func(k string, v *Stmt) {
		if v != nil {
			go v.Close()
		}
	}

After the overwrite nothing in the cache references the previous *sql.Stmt, so no later cleanup pass can close it. The window is the gap between an entry expiring and the background deleteExpired goroutine collecting its bucket — exactly when a hot query gets re-run.

Reproduction

The playground test prepares SELECT 1, then SELECT 2 half a cleanup tick later so both land in the same expiration bucket. Since deleteExpired waits for the newest entry in a bucket before collecting it, that second statement delays collection and opens the window; re-running SELECT 1 inside it takes the overwrite branch.

After the bucket has been collected, a counting database/sql driver has seen 3 prepares and 1 close (SELECT 2's). The first SELECT 1 statement is still open, with nothing left that could close it. (database/sql exposes no open-statement count, which is why the test needs its own driver.)

Versions

  • GORM: master @ 1d6ce99 (= v1.31.2)
  • Go: 1.25.12 linux/amd64 (playground CI, ubuntu-latest); also reproduced on go1.26.5 darwin/arm64
  • Driver: gorm.io/driver/sqlite v1.6.0 / mattn/go-sqlite3 v1.14.49 — the affected code is dialect-independent, so it reproduces on every driver

Related

#7831 is a use-after-close race in the same cache. Both are symptoms of statement lifetime being tied to cache-slot lifetime rather than to whether anyone is still using the statement, so a fix for either should account for the other.