Question
Stack vs Heap Allocation in Go: Structs, Escape Analysis, and Garbage Collection
Question
In Go, I am trying to understand how struct allocation works compared with C and Python.
These two functions appear to behave the same way:
func myFunction() (*MyStructType, error) {
var chunk *MyStructType = new(MyStructType)
// ...
return chunk, nil
}
func myFunction() (*MyStructType, error) {
var chunk MyStructType
// ...
return &chunk, nil
}
Both seem to create a struct and return a pointer to it.
In C, these would be very different:
- The first would allocate on the heap.
- The second would create a local stack variable.
- Returning the address of that local variable would be invalid after the function returns.
In Python-style languages, local names usually refer to heap-allocated objects, so the second form would not typically exist in the same way.
I understand that Go allows returning the address of a local variable, and that this is safe because the value can outlive the function call.
This leads to a few questions:
- In the first example, the struct is heap-allocated. What happens in the second example? Is it created on the stack or on the heap?
- If the second example starts on the stack, how can it remain valid after the function returns?
- If it is heap-allocated, why are structs still passed by value in Go rather than always by reference? What role do pointers still play?
Short Answer
By the end of this page, you will understand that Go does not treat var x T and new(T) as fixed promises about stack or heap placement. Instead, the compiler decides where values live using escape analysis. If a value must outlive the current function, Go allocates it so it remains valid, usually on the heap. You will also see why Go still has value semantics, why pointers are useful, and how garbage collection fits into the picture.
Concept
In Go, stack vs heap allocation is mostly a compiler decision, not something you directly control by syntax alone.
In languages like C, the storage duration of a variable is closely tied to how and where it is declared:
- local variables usually live on the stack
mallocallocates on the heap- returning a pointer to a local variable is invalid
Go is different. Go has:
- value types, including structs
- pointers to values
- garbage collection for memory that is no longer reachable
- escape analysis, which decides whether a value can safely stay on the stack or must be moved to the heap
The key rule
If a value's address is used in a way that may outlive the current function call, that value escapes. When that happens, the compiler typically allocates it on the heap.
So in these two forms:
chunk := new(MyStructType)
return chunk
and
var chunk MyStructType
return &chunk
both can end up producing equivalent behavior. In the second case, the compiler sees that chunk is returned by address, so it must remain valid after the function returns. Therefore, it allocates storage in a way that survives the function call.
Important idea: syntax does not guarantee stack or heap
These are not strict rules in Go:
new(T)does not automatically mean heapvar x Tdoes not automatically mean stack
The compiler decides based on usage.
Why this matters
This design gives Go two advantages:
- Safety: returning the address of a local variable is valid when needed.
- Performance: values that do not escape can stay on the stack, which is faster and creates less garbage for the GC.
Garbage collection and lifetimes
The garbage collector is responsible for reclaiming heap memory that is no longer reachable. Stack memory is not GC-managed in the same way; it is naturally released when the function call ends.
So the lifetime model is:
- if a value stays within the function and does not escape, stack allocation is often possible
- if a value must live longer, the compiler arranges heap allocation
- the GC later frees heap memory when nothing references it anymore
Why structs are still passed by value
Even if a struct happens to be heap-allocated, the language semantics are still value-based.
That means:
func f(s MyStructType)
receives a copy of the struct value.
Where the bytes physically live in memory is a separate implementation detail from how assignment and function calls behave at the language level.
Pointers still matter because they let you:
- avoid copying large structs
- modify the original value
- represent optional values (
nil) - share access to the same data across functions or objects
Mental Model
Think of Go values as packages, and the compiler as the shipping manager.
- A value is the package itself.
- A pointer is the delivery address to that package.
- The stack is a temporary worktable.
- The heap is long-term storage.
If a package is only needed during one small task, the manager keeps it on the worktable.
If someone needs to keep using that package after the current task finishes, the manager moves it to long-term storage and gives out its address.
So when you write:
var chunk MyStructType
return &chunk
it is like saying: “I need to give someone the address of this package after I leave the room.”
The compiler notices that and says: “Then this cannot stay on the temporary table. I will store it somewhere that remains valid.”
The important part is that you still created a value, not a magical reference object. A pointer is just a way to point to that value.
Syntax and Examples
Core syntax
Allocate a zero-valued struct and get a pointer
p := new(MyStructType)
phas type*MyStructType- the struct fields are zero-initialized
- allocation may be on stack or heap depending on escape analysis
Create a value, then take its address
var v MyStructType
p := &v
vhas typeMyStructTypephas type*MyStructType- if
pescapes,vmay be heap-allocated
Composite literal with address
p := &MyStructType{}
This is a common short form.
Example: both forms are valid
package main
import "fmt"
type User struct {
Name string
Age int
}
func makeUserWithNew() *User {
u := new(User)
u.Name = "Ava"
u.Age = 30
return u
}
func makeUserWithLocal() *User {
var u User
u.Name = "Ava"
u.Age = 30
return &u
}
func main() {
a := makeUserWithNew()
b := makeUserWithLocal()
fmt.Println(a.Name, a.Age)
fmt.Println(b.Name, b.Age)
}
Both functions work correctly.
Example: passing by value vs pointer
package main
import "fmt"
type Counter struct {
Value int
}
func incrementByValue(c Counter) {
c.Value++
}
func incrementByPointer(c *Counter) {
c.Value++
}
func main() {
c := Counter{Value: 10}
incrementByValue(c)
fmt.Println(c.Value) // 10
incrementByPointer(&c)
fmt.Println(c.Value) // 11
}
What this shows
- Passing
ccopies the struct. - Passing
&cshares access to the original struct. - This is true regardless of whether the underlying storage is stack or heap.
Step by Step Execution
Consider this function:
func buildPoint() *Point {
var p Point
p.X = 4
p.Y = 7
return &p
}
with:
type Point struct {
X int
Y int
}
Step-by-step
-
var p Point- A
Pointvalue is created. - At this moment, you should think of it as a normal local value.
- A
-
p.X = 4- The
Xfield is set to4.
- The
-
p.Y = 7- The
Yfield is set to7.
- The
-
return &p- You return the address of
p. - The compiler sees that code outside the function will still need access to
p. - Therefore
pescapes the function.
- You return the address of
-
Compiler decision
- Because
pescapes, the compiler allocates it so it survives after the function returns. - In practice, this usually means heap allocation.
- Because
-
Function returns
- The returned pointer still points to valid memory.
Compare with a non-escaping value
func sum() int {
var a int = 2
var b int = 3
return a + b
}
Here, a and b do not escape. They are usually good candidates for stack allocation.
How to inspect escape behavior
You can ask the Go compiler to show escape analysis information:
go build -gcflags="-m" ./...
You may see messages like:
moved to heap: p
That tells you the compiler decided the value escapes.
Real World Use Cases
Returning objects from constructor-like functions
A very common pattern in Go is returning a pointer from a factory function:
func NewConfig() *Config {
return &Config{Port: 8080}
}
This is used for:
- configuration objects
- service instances
- database wrappers
- HTTP handlers
Building response objects in APIs
When preparing a response struct in a helper function, you may return a pointer so callers can mutate it or avoid copying:
func BuildUserResponse(name string) *UserResponse {
return &UserResponse{Name: name}
}
Large structs in data processing
If a struct is large, passing pointers can reduce copying costs.
Examples:
- parsed records
- image metadata
- request context objects
- cached entities
Optional data with nil
Pointers are often used when a value may be absent:
func FindUser(id int) *User {
// return nil if not found
return nil
}
A plain struct value cannot be nil, but a pointer can.
Methods that modify receiver state
Pointer receivers let methods update the original struct:
func (c *Counter) Increment() {
c.Value++
}
Real Codebase Usage
In real Go codebases, developers usually focus less on “forcing stack or heap” and more on writing clear code while being aware of escape costs in performance-sensitive paths.
Common patterns
Constructor functions returning pointers
func NewServer(addr string) *Server {
return &Server{Addr: addr}
}
This is common when:
- the struct has methods with pointer receivers
- the struct is large
- the struct contains mutable state
Returning values for small immutable-like structs
func NewPoint(x, y int) Point {
return Point{X: x, Y: y}
}
This is common when:
- the struct is small
- copying is cheap
- value semantics are preferred
Validation before returning pointers
func NewUser(name string) (*User, error) {
if name == "" {
return nil, fmt.Errorf("name is required")
}
return &User{Name: name}, nil
}
This combines:
- guard clauses
- explicit error handling
- pointer returns for optional success values
Avoiding unnecessary escapes in hot code
In performance-critical code, developers sometimes avoid patterns that cause values to escape unnecessarily, because heap allocations can:
- increase GC work
- reduce throughput
- add latency
For example, a short-lived helper object used only inside a function may be kept as a local value rather than returned or stored elsewhere.
Rule of thumb in real projects
- Choose value types when copying is cheap and ownership should be clear.
- Choose pointers when mutation, sharing, nil, or reduced copying is important.
- Let the compiler decide stack vs heap unless profiling shows a real problem.
Common Mistakes
Mistake 1: Assuming new(T) always means heap allocation
p := new(MyStructType)
Beginners often think this always allocates on the heap. In Go, it may or may not. The compiler decides.
Avoid it
Think of new(T) as:
- create a zero value of type
T - return
*T
Do not treat it as a direct heap command like malloc.
Mistake 2: Assuming local variables always live on the stack
func f() *MyStructType {
var x MyStructType
return &x
}
In C, this would be invalid. In Go, it is fine because x escapes.
Avoid it
Remember: local declaration does not guarantee stack allocation.
Mistake 3: Confusing location with semantics
A struct may be heap-allocated, but assigning it still copies the value.
package main
type Data struct {
N int
}
func main() {
a := Data{N: 1}
b := a
b.N = 99
_ = a // a.N is still 1
}
Avoid it
Separate these ideas:
- where memory lives = implementation detail
- copy vs share behavior = language semantics
Mistake 4: Using pointers everywhere
Broken design example:
func add(a *int, b *int) *int {
result := *a + *b
return &result
}
This works, but it is awkward and unnecessary for simple integers.
Better
func add(a, b int) int {
return a + b
}
Avoid it
Use pointers when they provide a real benefit:
- mutation
- shared access
- nilability
- avoiding expensive copies
Mistake 5: Forgetting that pointer receivers can mutate shared state
func (c *Config) SetPort(p int) {
c.Port = p
}
If several parts of the program share the same pointer, one change affects all of them.
Avoid it
Use value copies when you want isolation.
Mistake 6: Optimizing allocation style too early
Beginners sometimes rewrite code just to “keep things on the stack.”
Avoid it
Prefer:
- readable code first
- measure with benchmarks and profiling
- inspect escapes only when performance matters
Comparisons
| Concept | Value Type | Pointer Type |
|---|---|---|
| Meaning | The actual data | Address of the data |
| Function call | Copies the value | Copies the pointer |
Can be nil | No | Yes |
| Can modify original through it | No | Yes |
| Good for small structs | Yes | Sometimes unnecessary |
| Good for large/mutable/shared structs | Sometimes costly | Often useful |
new(T) vs &T{}
| Syntax | Result | Typical use |
|---|---|---|
new(T) | *T to zero value | Simple zero-initialized allocation |
&T{} | *T to initialized value | More common when setting fields |
Example:
u1 := new(User)
u2 := &User{}
Both produce *User.
Stack vs heap in Go vs C
| Language | Local variable | Returning address of local |
|---|---|---|
| C | Usually stack | Invalid |
| Go | Compiler decides | Valid if value escapes |
Passing by value vs passing a pointer
| Form | Example | Effect |
|---|---|---|
| By value | f(s) | s is copied |
| By pointer | f(&s) | function can access original |
The important distinction is not stack vs heap, but copy vs shared access.
Cheat Sheet
Quick rules
var x Tcreates a value of typeTnew(T)returns*T&xgives a pointer tox&T{...}creates a pointer to a struct literal- Go uses escape analysis to choose stack or heap
- Returning the address of a local variable is safe in Go
- Structs are still passed by value unless you use pointers explicitly
Common forms
var s MyStructType // value
p := &s // pointer to value
p2 := new(MyStructType) // pointer to zero value
p3 := &MyStructType{} // pointer to struct literal
Mental rules
- Syntax does not guarantee allocation location
- Heap allocation is about lifetime
- Value semantics are about copying behavior
- Pointers are about sharing and mutation
When values often make sense
- small structs
- immutable-style usage
- clearer ownership
- no need for
nil
When pointers often make sense
- need to modify original data
- want to avoid copying large structs
- need
nilto mean “missing” - methods use pointer receivers
Useful compiler command
go build -gcflags="-m" ./...
Look for messages such as:
moved to heap: x
Important edge case
A variable being declared locally does not mean it must be on the stack. If it escapes, the compiler can place it on the heap.
FAQ
Is new() the same as heap allocation in Go?
No. new(T) creates a pointer to a zero value of type T, but the compiler still decides whether the storage goes on the stack or heap.
Why is returning &localVar safe in Go?
Because if the compiler sees that the local variable escapes the function, it allocates it so the value remains valid after return.
Are structs in Go passed by value or by reference?
Structs are passed by value. If you want shared access or mutation, pass a pointer.
If something is heap-allocated, does that mean it is passed by reference?
No. Allocation location and passing semantics are different concepts. A heap-allocated struct can still be copied as a value.
When should I use a pointer to a struct in Go?
Use a pointer when you need to modify the original, avoid copying a large struct, support nil, or share state.
Does garbage collection manage stack memory too?
Not in the same way. Stack frames are cleaned up automatically when functions return. Garbage collection is mainly concerned with unreachable heap objects.
How can I tell whether a variable escapes to the heap?
Use the compiler's escape analysis output:
go build -gcflags="-m"
Is returning a struct value instead of a pointer more idiomatic?
Sometimes, yes. For small structs that do not need mutation or nil, returning a value is often simple and clear.
Mini Project
Description
Build a small Go program that creates and updates user profiles using both value returns and pointer returns. This project demonstrates how Go handles structs, copying, mutation, and returning addresses of local variables safely.
Goal
Create a program that shows the practical difference between returning a struct by value and returning a pointer to a struct.
Requirements
- Define a
Userstruct with at leastNameandScorefields. - Write one function that returns a
Userby value. - Write one function that returns
*Userby pointer. - Write update functions that try to modify the user using both value and pointer parameters.
- Print results to show which updates affect the original data.
Keep learning
Related questions
Are Go Maps Passed by Value or Reference?
Learn Go map parameter and return semantics: maps are passed by value, while copies share underlying data. Know when map pointers are useful.
Authentication for Go REST APIs: Sessions, Tokens, and Middleware
Learn practical authentication patterns for Go REST APIs, including password hashing, server sessions, bearer tokens, middleware, and security rules.
Automatic Build Versioning in Go: Embed Incrementing Build Numbers
Learn how to add automatic build versioning in Go using linker flags, build metadata, CI counters, and Git-based version values.