What I learned
GORM’s clause.Returning{} causes every UPDATE (and CREATE) to emit UPDATE ... RETURNING * instead of a plain UPDATE. The result is silently discarded if the caller doesn’t use it — but PostgreSQL still assembles and streams the full row back, changing the transaction round-trip.
Warning
Silent cost —
clause.Returning{}is often added for convenience in one call path and left in permanently. If the return value isn’t used, it’s pure overhead that also creates a TCP-stall vulnerability (see postgres-returning-tcp-session-stall).
Remove it when the return value isn’t used
// Before — emits UPDATE ... RETURNING *; result never read
return db.Model(&models.EntityParty{}).
Clauses(clause.Returning{}).
Where(...).
Updates(...).Error
// After — plain UPDATE; commits in one round-trip
return db.Model(&models.EntityParty{}).
Where(...).
Updates(...).ErrorAdd a context timeout as a safety net
If the DB call stalls for any reason, a context timeout lets the goroutine cancel after a bounded time. The driver sends a cancellation to PostgreSQL and the caller moves on.
func (r *EntityPartyRepository) UpdateLastSyncTime(
db *gorm.DB,
partyId, updateFieldName string,
lastSyncAt time.Time,
) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return db.WithContext(ctx).
Model(&models.EntityParty{}).
Where(...).
Updates(...).Error
}Note
The timeout protects the goroutine, not the PostgreSQL session. If the TCP connection is already dead, PostgreSQL won’t know until keepalive fires regardless of the context cancellation. But without
RETURNING *, PostgreSQL has nothing to wait for anyway — the session resolves immediately on commit.
See also
- postgres-returning-tcp-session-stall — why RETURNING * + dead TCP = 45-minute phantom session in Performance Insights