Pointers and Memory Allocation
How Go allocates memory for pointer values using the new built-in, why pointers to local variables are safe, and how to choose between pointer and value receivers.
Go gives you direct control over whether you work with a value itself or a pointer to it, but the language also takes on the hard part of deciding where in memory that value lives. This section picks up from the pointer fundamentals and looks at the mechanics underneath: how you allocate memory for pointer targets, what happens when you point to a local variable, and how method receivers connect allocation decisions to program behavior.
The three areas covered here—new, escape analysis, and receivers—form a practical toolkit. You will know exactly what new returns and when it is useful, why taking the address of a local variable in Go does not cause the disaster it would in C, and how to pick a value or pointer receiver with confidence.
The new Built-in
The new function allocates memory for a value of a given type, sets every byte of that memory to zero, and returns a pointer to it. That is all it does.
p := new(int) // p is *int, points to 0
The expression new(T) is equivalent to creating a zero value of type T and then taking its address, but in one concise step. The code above is functionally identical to:
var i int
p := &i
Because new returns a pointer, you can immediately dereference it to read or write the value.
*p = 42
fmt.Println(*p) // 42
This example exists to make a specific point: new gives you a fully usable, zero-initialized variable in a single line. You can think of it as a factory for a named-type variable that you can only reach through its address. Without new, you would need to declare a local variable, then take its address—two steps.
When you run that snippet, p points to an integer whose initial value is 0, then you overwrite it with 42. If you had not used new, you might have declared an int on the stack and taken its address, which is fine but less intentional when you want a pointer from the start.
One subtle detail beginners overlook is that new does not initialize any internal structure beyond zeroing. For a struct, that means every field is set to its zero value. For a pointer field, that zero value is nil. new will not give you a ready-to-use nested pointer—you would still need to initialize that field yourself.
new vs make — Different Purposes:
The new built-in is for allocating a single value of any type and getting a pointer. The make built-in, by contrast, only works with slices, maps, and channels, and it returns an initialized, ready-to-use value—not a pointer.
Do not use new for slices or maps expecting a usable data structure. new([]int) returns a *[]int pointing to a nil slice; make([]int, 0) returns a slice value you can append to immediately. The zero value of a slice is nil, and new just gives you a pointer to that nil. The zero value of a map is also nil, and writing to a nil map causes a runtime panic.
When new Makes Sense
Because new(T) is so short, it appears in code where the writer wants to emphasize that they are allocating a fresh zero value and intend to work through a pointer from the start. A common pattern is inside a constructor function that returns a pointer to a struct.
type Config struct {
Timeout int
Retries int
}
func NewDefaultConfig() *Config {
c := new(Config)
c.Timeout = 30
c.Retries = 3
return c
}
This pattern communicates that the caller always gets a pointer to a Config, and the default values are built from a zero base. You could also use a composite literal: return &Config{Timeout: 30, Retries: 3}. The two are equivalent in effect; new makes the zero-then-set sequence explicit. Which one you use is a matter of taste, but knowing that new exists prevents confusion when you encounter it in other people's code.
Correct initialization with new:
If you see p := new(MyStruct) and then each field being assigned immediately afterward, the program is correctly set up—the pointer is valid, the memory is zeroed, and the assignments are safe. You can trust that p.Field is not garbage; it is the zero value of that field's type.
Pointers to Local Variables
In several older compiled languages, returning the address of a local variable from a function is a classic bug. The variable lives on the stack, the stack frame is destroyed when the function returns, and the pointer becomes dangling—it points to memory that may be reused at any moment. Go eliminates that bug entirely without forcing you to manually allocate on the heap. You can write this:
func createInt() *int {
n := 42
return &n
}
When you call createInt, you get a valid, stable *int pointing to the value 42. This works because the Go compiler performs escape analysis: it examines how a variable is used and decides whether the variable can safely live on the stack for the duration of the function call, or must be “promoted” to the heap to survive after the function returns.
If a variable’s address is taken and that address survives the function (by being returned, stored in a global, or sent to another goroutine), the compiler marks the variable as “escaping to the heap.” The runtime then allocates it in a region of memory managed by the garbage collector, so the pointer remains valid as long as any reference exists.
The opposite is also true: if the compiler can prove that a pointer never leaves the function’s scope, it will keep the value on the stack even though you wrote & to get its address. No heap allocation happens, and the stack memory is automatically reclaimed when the function returns. This is not an optimization you must opt into; it is the default behavior of the go compiler.
You can inspect escape decisions:
Add -gcflags="-m" to your go build or go run command to see which variables the compiler says “escape to heap” and which “does not escape.” This is one of the most immediate ways to understand how the compiler reasons about your code.
go build -gcflags="-m" main.go
Think of escape analysis as the compiler doing a safety check on every pointer. Rather than letting you manually say “heap” or “stack” and risking a dangling pointer, Go makes the safest choice automatically. For a beginner, the mental model is simple: take the address of a local variable whenever you need to; the compiler will put it where it belongs. You do not need to track stack lifetimes yourself.
A Concrete Example of Escape vs No Escape
Consider two functions that both use a local pointer. The difference in their behavior is invisible to you, but the compiler output shows it clearly.
package main
type Data struct {
Value int
}
func keepLocal() int {
d := &Data{Value: 100}
return d.Value
}
var global *Data
func letEscape() {
d := &Data{Value: 200}
global = d
}
In keepLocal, the address of d is only used to reach its field, and then the function returns. The compiler sees that d does not survive the call and can place it on the stack. In letEscape, the value of d is stored in a package-level variable, so it must outlive the function—d escapes to the heap.
When you run go build -gcflags="-m" on this code, the compiler reports something like:
./main.go:9: &Data{...} does not escape
./main.go:14: &Data{...} escapes to heap
The value here is not in memorising every rule, but in knowing that the tooling exists and that your pointer to a local variable is not inherently “unsafe.” It will be placed where it needs to be.
Escape analysis is not a loophole for performance:
A common mistake is to assume that because &localVar works, you should use it everywhere to “avoid copying.” Every time a variable escapes to the heap, the garbage collector must manage it later. Heap allocation and garbage collection have a real cost. Rely on stack copies for small, short-lived values until a profiler tells you otherwise. The compiler can keep many pointers on the stack, but passing values can be cheaper than heap allocation plus scanning.
Pointer Receivers vs Value Receivers
Methods in Go can be attached to a type with either a value receiver or a pointer receiver. This choice directly involves the memory-allocation and copying mechanics you have just seen.
A value receiver operates on a copy of the value. The method receives its own local variable—a complete duplicate—and any modifications the method makes are local to that copy. The original caller’s value is untouched. This is the same “pass by copy” behavior you get when you pass a non-pointer argument to a function.
type Counter struct {
Count int
}
func (c Counter) Increment() {
c.Count++
}
Calling c.Increment() on a Counter variable will increase the copy’s Count field, but the caller’s Counter.Count remains unchanged. This surprises developers coming from languages where methods always refer to the original object.
A pointer receiver gives the method direct access to the value the caller holds. The receiver is a pointer to the original, so changes made through it are visible to the caller.
func (c *Counter) Increment() {
c.Count++
}
Now Increment modifies the caller’s Counter and the change persists.
These two simple examples demonstrate the core trade-off. A value receiver guarantees isolation: the method cannot accidentally mutate the caller’s data. That is useful for small, immutable types or when you want the method to return a new value instead of modifying in place. A pointer receiver is the tool you use when a method is meant to change the receiver’s state or when copying the value would be too expensive.
How the Choice Affects Method Sets
A slightly more advanced point, but one that affects everyday code, is that the set of methods available on a value versus a pointer of a type are not the same. If you define a method with a pointer receiver, that method belongs to the method set of *T, not to the method set of T. A value of type T can still call that method if it is addressable (e.g., a local variable), because the compiler automatically takes its address. However, when it comes to satisfying an interface, a value of type T only has methods with value receivers; a pointer *T has both pointer- and value-receiver methods.
type Modifier interface {
Increment()
}
func apply(m Modifier) {
m.Increment()
}
If Increment has a pointer receiver *Counter, then apply(myCounter) where myCounter is a Counter value will not compile—Counter does not implement Modifier. You would need to pass &myCounter instead. This is often the first concrete friction a beginner hits, and it is a direct result of the pointer/value receiver distinction.
Choosing between pointer and value receivers:
The Go team suggests picking the receiver type that matches the typical usage pattern of the type. If the type is used primarily through a pointer, make the methods pointer receivers. If values are the norm and the type is small and immutable, value receivers make sense. Once you pick one, consistency matters: methods on the same type should generally all be pointer receivers or all be value receivers—mixing them can confuse callers and complicate interface satisfaction.
Performance and Safety Considerations
Choosing a pointer receiver avoids copying the entire value on every method call. For a large struct (hundreds of bytes or more), the copying cost can be measurable, and a pointer receiver reduces that cost to copying a single word—the pointer itself. That said, a pointer receiver also means the method can mutate the original, which can introduce unexpected side effects if the caller passes a shared reference.
Pointer receiver and shared state:
When you pass a pointer to a method, you are handing over the ability to change the original value. In concurrent code, this means you must protect access with synchronization primitives like sync.Mutex if multiple goroutines call that method. Value receivers, by contrast, inherently create a snapshot, giving each call its own independent copy—though the receiver might contain fields that are themselves pointers, which can still cause races.
For most small-to-medium structs used in single-goroutine contexts, the right call is often the one that makes the program’s intent clearest. Use a pointer receiver if the method changes the receiver’s logical state. Use a value receiver if the method is read-only and the type feels like a small value type (e.g., time.Time uses value receivers, and it is perfectly safe and efficient).
Summary
new, escape analysis, and method receivers are different facets of the same underlying idea: Go manages memory in a way that makes pointers safe and predictable, but you still control the semantics that matter—whether a method sees a copy or the original, and where a value lives. new is the simplest allocator: it gives you a pointer to a zero-filled value. Escape analysis means you never have to worry about dangling pointers to local variables, but it also rewards you for keeping values on the stack when you can. Pointer and value receivers let you encode mutability and copy cost directly into a type’s API, and the interface-satisfaction rules make that choice visible at compile time rather than during debugging.