Right, let’s talk about one of the most brilliant and terrifying features of Go: its garbage collector and what it means for your pointers. You’ve probably come from a language where you had to constantly worry about free() or delete, meticulously pairing every malloc with its destruction like a morbid matchmaking service. In Go, we fire the matchmaker. The garbage collector (GC) is our cleanup crew, and it’s spectacularly good at its job.

The core promise, and the reason you can sleep at night, is this: if a pointer is still reachable, the value it points to is fair game. If it’s not reachable, the GC will eventually haul it away. This eliminates the entire class of “dangling pointer” bugs that plague languages like C and C++. A dangling pointer is one that points to a memory location that has already been freed. Dereferencing it is like trying to get mail from a house that’s been demolished. You might get nothing, you might get someone else’s mail, or you might fall into a sinkhole. In Go, this simply cannot happen. The GC will never collect memory that is still pointed to by a live, reachable variable.

How the GC Keeps You Out of Trouble

Think of the GC as a paranoid librarian. It periodically goes through all the variables in your program (starting from the “roots” like global variables and stack frames) and marks every piece of memory that’s still in use. Everything that isn’t marked? Well, that’s just clutter. It gets swept away. This “mark-and-sweep” process (it’s more sophisticated now with generational and concurrent collection, but the principle holds) ensures that your pointers are always valid.

The key term is reachable. Let’s look at this in action.

func createThing() *int {
    // We allocate memory for an int on the heap (escape analysis determines this).
    value := 42
    // We return the address of this local variable.
    // This is totally fine! The Go runtime knows that 'value' must survive
    // because its pointer is escaping the function scope.
    return &value
}

func main() {
    myPointer := createThing()
    fmt.Println(*myPointer) // Prints 42. The value is still there!
}

The value inside createThing doesn’t vanish when the function returns because the GC sees that myPointer in main is still holding a reference to it. The memory is only considered for collection when you’re done with myPointer itself—specifically, when there are no more references to that memory location anywhere in your program.

The One Way to Shoot Yourself in the Foot (Kinda)

Okay, I said “no dangling pointers,” and that’s true from the language’s perspective. But you can still create logical errors that feel the same if you’re not careful with data structures. The classic example is taking a pointer to a loop iterator variable. This is a gotcha that gets everyone once.

func main() {
    var pointers []*int
    values := []int{1, 2, 3, 4}

    for _, v := range values {
        // This is a BAD idea. We're taking the address of the loop variable 'v'.
        // There's only one 'v' whose value changes each iteration.
        pointers = append(pointers, &v)
    }

    for _, p := range pointers {
        fmt.Println(*p) // You expect 1, 2, 3, 4? Nope. Prints 4, 4, 4, 4.
    }
}

Why does this happen? Because the loop variable v is reused in each iteration. Its address never changes; only the value at that address changes. So every single pointer in our slice is pointing to the exact same memory location, which, at the end of the loop, holds the value 4. The GC did its job perfectly—the memory was reachable and not collected. The bug is entirely our fault for misunderstanding how the language works. The fix is to create a new variable in each loop’s scope:

    for _, v := range values {
        v := v // Create a new variable scoped to this iteration!
        pointers = append(pointers, &v) // Now this points to a unique address.
    }

Best Practices: Working With the GC

Your main job is to make the GC’s job easy. A happy GC is a performant program.

  1. Minimize Pointers: This is huge. If you can use a value, use a value. Pass slices and structs by value when it makes sense (for small ones). This reduces the amount of memory the GC has to track and can keep things on the stack, which is automatically cleaned up. Don’t prematurely optimize by using pointers everywhere; you’ll just create more work for the GC.

  2. Let Scope Do the Work: The beauty of Go is that you often don’t need to think about allocation. You define a variable in a scope, you use it, and when that scope ends, if nothing else is pointing to it, the GC will handle it. Write clear, simple code and let the runtime worry about the cleanup.

  3. Be Wary of Large Caches & Global State: The GC can only collect what is unreachable. If you have a global map[string]*BigObject that you never delete from, that’s a memory leak, plain and simple. The GC can’t help you because the pointers are still reachable via the global variable. You need to implement logic to remove old entries yourself. This isn’t a flaw in the GC; it’s just you being a hoarder.

So, relax. The designers got this one unequivocally right. The garbage collector is your brilliant, silent partner. It handles the tedious, error-prone memory management for you, eliminating a whole category of nasty bugs. Your responsibility shifts from micromanaging memory lifetimes to writing clear code and understanding scope. It’s a trade-off I’ll take any day of the week.