A Go application is leaking memory because the garbage collector can’t reclaim objects that are still referenced, even though they are no longer logically needed by the program.

Common Causes and Fixes for Memory Leaks in Go

This is a classic scenario: your Go app runs fine for a while, then memory usage climbs and never comes down, eventually leading to performance degradation or crashes. The root cause is almost always that pointers to objects are being held longer than necessary, preventing the Go garbage collector (GC) from freeing their memory.

1. Goroutine Leaks

Diagnosis: The most frequent culprit is a goroutine that never exits because it’s blocked waiting on a channel that will never be written to, or it’s holding onto resources. Check the number of active goroutines:

curl "http://localhost:port/debug/pprof/goroutine?debug=2"

Look for a steadily increasing number of goroutines, especially if they are stuck in states like chan receive or select.

Fix: Ensure all goroutines have a clear exit condition. Often, this involves using context.Context for cancellation signals or closing channels to unblock select statements.

Example: If a goroutine reads from a channel dataChan, and the sender stops sending without closing the channel, the reader will block forever.

// Leaky
go func() {
    for {
        <-dataChan // Blocks forever if dataChan is never closed
    }
}()

// Fixed with context
ctx, cancel := context.WithCancel(context.Background())
go func() {
    for {
        select {
        case <-dataChan:
            // process data
        case <-ctx.Done():
            return // Exit when context is cancelled
        }
    }
}()
// ... later, to stop the goroutine: cancel()

Why it works: Explicitly signaling cancellation or closure provides a path for the goroutine to terminate, releasing its resources and allowing the GC to collect its objects.

2. Unclosed http.Response.Body

Diagnosis: When you make HTTP requests within your application, forgetting to close the resp.Body is a common leak. Each unclosed resp.Body keeps a connection open and consumes memory. Use net/http/pprof to inspect active HTTP requests. You might see an increasing number of connections in a TIME_WAIT state if you’re using http.Client without proper cleanup. Check for open file descriptors, which can indicate unclosed network resources.

Fix: Always defer resp.Body.Close() immediately after checking for an error on the response.

Example:

// Leaky
resp, err := http.Get("http://example.com")
if err != nil {
    // handle error
}
// ... use resp.Body ...
// Missing: resp.Body.Close()

// Fixed
resp, err := http.Get("http://example.com")
if err != nil {
    // handle error
    return
}
defer resp.Body.Close() // Always close the body
// ... use resp.Body ...

Why it works: resp.Body.Close() releases the underlying network connection and associated buffers back to the system or connection pool, allowing them to be reused or garbage collected.

3. Global Variables and Long-Lived Slices/Maps

Diagnosis: Storing data indefinitely in global variables, especially slices or maps, can lead to leaks if items are added but never removed. Profile memory allocations and examine the heap:

go tool pprof http://localhost:port/debug/pprof/heap

Look for large, continuously growing data structures in the heap dump, often associated with global variables.

Fix: If you’re using global maps or slices to cache data, implement a mechanism to periodically prune old or unused entries. For slices, consider re-slicing to an empty slice (mySlice = mySlice[:0]) if you want to retain the underlying array capacity but clear the elements. For maps, explicitly delete entries.

Example: A global map storing user sessions that aren’t expired.

var userSessions = make(map[string]SessionData)

// Leaky: sessions are never removed
func AddSession(userID string, data SessionData) {
    userSessions[userID] = data
}

// Fixed: periodic cleanup or on session end
func AddSession(userID string, data SessionData) {
    userSessions[userID] = data
}

func CleanupSessions() {
    // Example: remove sessions older than 1 hour
    cutoff := time.Now().Add(-time.Hour)
    for userID, session := range userSessions {
        if session.LastAccess.Before(cutoff) {
            delete(userSessions, userID)
        }
    }
}

Why it works: By actively removing or invalidating entries, you break the references held by the global data structure, making the associated memory eligible for GC.

4. Finalizer Leaks

Diagnosis: Go’s runtime.SetFinalizer allows you to register a function to be called when an object is about to be garbage collected. However, finalizers can unintentionally keep objects alive. If a finalizer function itself holds a reference to the object it’s supposed to be cleaning up, or if the object is reachable through another path after the finalizer runs, it won’t be collected. Use runtime.GC() and then inspect the heap to see if objects with finalizers are still present when they shouldn’t be.

Fix: Ensure that the finalizer function does not hold a reference to the object being finalized, or if it must, that the reference is set to nil within the finalizer itself. More commonly, avoid finalizers if possible, or ensure the object becomes unreachable after the finalizer has completed its work.

Example:

type LeakyResource struct {
    data []byte
    // ... other fields
}

func (lr *LeakyResource) finalize() {
    // Leaky: If finalize is called, and lr itself is still referenced,
    // or if finalize tried to keep lr alive, it might not be GC'd.
    // A common mistake is for the finalizer to return a value that's
    // then reassigned to the original pointer, keeping it alive.
    // The correct pattern is usually to just clean up internal state.
    fmt.Println("Finalizing resource:", lr)
    // If lr is still reachable from somewhere else, it won't be collected.
    // If finalize itself stored a reference back to lr, it would leak.
}

func NewLeakyResource(size int) *LeakyResource {
    r := &LeakyResource{
        data: make([]byte, size),
    }
    runtime.SetFinalizer(r, (*LeakyResource).finalize)
    return r
}

// To avoid leaks, ensure 'r' is nilled out or becomes unreachable
// after SetFinalizer is called and the object is no longer needed.

Why it works: Finalizers are tricky. The key is that once a finalizer runs, the object is not immediately collected. It’s put back on the heap, and the GC will try to collect it again on a subsequent pass. If the finalizer makes it reachable again (e.g., by returning a non-nil value that gets assigned back to the original pointer), it will never be collected. The fix is to ensure the object is truly unreachable after the finalizer has done its job.

5. Timers and Tickers Not Stopped

Diagnosis: time.NewTimer and time.NewTicker create goroutines that run in the background. If these are not explicitly stopped using their .Stop() method when they are no longer needed, they will continue to hold references and consume resources indefinitely. Profile goroutines, looking for timer or ticker related goroutines that persist long after they should have been retired.

Fix: Always call .Stop() on timers and tickers when they are no longer required.

Example:

// Leaky
ticker := time.NewTicker(1 * time.Second)
go func() {
    for t := range ticker.C {
        fmt.Println("Tick at", t)
    }
    // Ticker is never stopped, goroutine lives forever
}()

// Fixed
ticker := time.NewTicker(1 * time.Second)
go func() {
    defer ticker.Stop() // Ensure ticker is stopped when goroutine exits
    for t := range ticker.C {
        fmt.Println("Tick at", t)
    }
}()
// ... logic to eventually cause this goroutine to exit (e.g., via context cancellation)

Why it works: Calling .Stop() on a timer or ticker prevents it from sending further values on its channel and signals its internal goroutine that it can exit.

6. Large Objects in Caches (e.g., sync.Map or custom maps)

Diagnosis: While sync.Map is designed for concurrent use, it doesn’t automatically evict entries. If you’re using it (or a regular map with mutexes) as a cache and never remove items, memory will grow. Use heap profiling (go tool pprof ... heap) to identify large, continuously growing map structures. Examine the keys and values being stored to understand what’s being retained.

Fix: Implement an eviction policy. This could be time-based (TTL), size-based (LRU), or based on specific application logic. For simple TTL, you might store a timestamp with each cache entry and periodically scan to remove expired ones.

Example: A cache using sync.Map with a TTL.

type CacheEntry struct {
    Value      interface{}
    Expiration int64 // Unix timestamp
}

var cache = sync.Map{}

func SetWithTTL(key string, value interface{}, ttl time.Duration) {
    expiration := time.Now().Add(ttl).UnixNano()
    cache.Store(key, CacheEntry{Value: value, Expiration: expiration})
}

func Get(key string) (interface{}, bool) {
    entry, ok := cache.Load(key)
    if !ok {
        return nil, false
    }
    cacheEntry := entry.(CacheEntry)
    if time.Now().UnixNano() > cacheEntry.Expiration {
        cache.Delete(key) // Evict expired entry
        return nil, false
    }
    return cacheEntry.Value, true
}

// Periodically run a cleanup goroutine
func StartCleanup(interval time.Duration) {
    go func() {
        ticker := time.NewTicker(interval)
        defer ticker.Stop()
        for range ticker.C {
            cache.Range(func(key, value interface{}) bool {
                cacheEntry := value.(CacheEntry)
                if time.Now().UnixNano() > cacheEntry.Expiration {
                    cache.Delete(key)
                }
                return true // Continue iteration
            })
        }
    }()
}

Why it works: By actively checking and removing entries that have passed their expiration time, you ensure the cache doesn’t grow indefinitely and that stale data is reclaimed.

After fixing these common leaks, you might encounter issues related to excessive GC pressure or latency spikes if the heap is still very large, even if it’s no longer growing.

Want structured learning?

Take the full Golang course →