Go Gotchas That Only Appear After Thousands of Lines of Production Code
Most Go content stops at “use goroutines, not threads.” This post doesn’t. These are the patterns that separate code that works from code…
Go Gotchas That Only Appear After Thousands of Lines of Production Code
Most Go content stops at “use goroutines, not threads.” This post doesn’t. These are the patterns that separate code that works from code that holds up under production load and the anti-patterns that reviewers quietly flag in your PRs.
1. You’re probably leaking goroutines
Goroutines are cheap to spawn. They are not free to forget. A goroutine blocked on a channel read with no corresponding writer will live silently until your process dies. No panic, no log line. Just a slow memory creep that only shows up in runtime.NumGoroutine() at 3am.
⚠️ Rule: Every goroutine you launch needs a defined exit condition. “It’ll finish eventually” is not a contract.
Anti-pattern:
func process(jobs <-chan Job) {
go func() {
for j := range jobs { // blocks forever if sender exits
handle(j)
}
}()
}
Fix: pass a context and select on ctx.Done():
func process(ctx context.Context, jobs <-chan Job) {
go func() {
for {
select {
case <-ctx.Done():
return
case j, ok := <-jobs:
if !ok { return }
handle(j)
}
}
}()
}
2. Interface pollution: the silent coupling
Go’s implicit interface satisfaction is its superpower and its most misused feature. The Java instinct define interfaces up front, at the producer is the wrong mental model.
In Go, interfaces belong at the consumer. If your package exports a UserRepository interface with 12 methods, you've just made every caller implement all 12.
Producer-side (Java brain) ❌
// pkg/user/repo.go
type Repository interface {
Create(User) error
FindByID(int) User
List() []User
Delete(int) error
Update(User) error
// 7 more methods...
}
Consumer-side (Go brain) ✅
// pkg/invoice/service.go
type userFinder interface {
FindByID(int) User
}
type Service struct {
users userFinder // tiny, testable
}
The smaller interface is also trivially mockable in tests no mock generation libraries needed. Just a struct with one method.
3. Escape analysis where your allocations actually go
Most Go developers treat the heap as automatic. The compiler decides but you can observe and influence it. Run go build -gcflags="-m" and you'll see exactly which variables escape to the heap.
- Returning a pointer from a function? Heap.
- Storing in an interface? Heap.
- Passing to a goroutine? Almost certainly heap.
func stackAlloc() Point {
p := Point{X: 1, Y: 2}
return p // copied out stays on stack
}
func heapAlloc() *Point {
p := &Point{X: 1, Y: 2}
return p // escapes to heap GC pressure
}
// In hot paths: prefer value receivers and
// return values over pointers where feasible
💡 Heap allocations aren’t always avoidable or bad but in a hot path called millions of times per second, even small allocations compound into meaningful GC pauses. Profile first with
pprof, optimize second.
4. context.WithTimeout isn't enough cancel it too
A surprising number of production bugs come from this one. context.WithTimeout returns a cancel function. If the operation completes before the timeout, you still need to call that cancel to release the timer resource.
Forgetting it is a slow leak one context object and timer per request, never freed until GC eventually catches up.
ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel() // always. even if timeout fires first.
result, err := db.QueryContext(ctx, query)
// defer cancel() ensures the timer is released
// regardless of which path the code takes
5. The sync.Map trap
sync.Map looks like the obvious choice for concurrent map access. It usually isn't.
Its performance wins are narrow: write-once-read-many workloads with disjoint key sets across goroutines. For a general-purpose cache with mixed reads and writes, a plain map protected by a sync.RWMutex benchmarks faster in most real workloads.
Don’t cargo-cult sync.Map because it feels more "concurrent."
📊 Always benchmark your actual access pattern.
sync.Maphas higher overhead per operation than a mutex-protected map for mixed read/write. Usego test -bench=. -benchmembefore committing to either.
Hot take
“Go’s error handling verbosity is a feature, not a bug it forces you to think about failure modes at the call site. The teams I’ve seen struggle most with Go are the ones trying to make it look like the language they came from.”
메타데이터
- post_id
- a35157490331
- slug
- go-gotchas-that-only-bite-veterans-a35157490331
- url
- https://medium.com/@moksh.9/go-gotchas-that-only-bite-veterans-a35157490331
- canonical_url
- https://medium.com/@moksh.9/go-gotchas-that-only-bite-veterans-a35157490331
- author_url
- https://medium.com/@moksh.9
- status
- ok
- fetched_at
- 2026-06-15 20:49:13