Pointers and Memory Management
For developers with .NET (C#) experience, this guide covers pointers, pass-by-value, escape analysis, and GC basics.
1. Pointer Basics
Go pointers resemble those in C/C++, but pointer arithmetic is not allowed. Unlike C# unsafe pointers, they can be used safely, much like references.
x := 10
p := &x // take the address of x (type *int)
fmt.Println(*p) // 10: dereference
*p = 20
fmt.Println(x) // 20
| Syntax | Meaning |
|---|---|
&x | Take the address of x |
*p | Read/write the value pointed to by p |
*T | Pointer type to T |
new(T) | Allocate a zero value of T and return a pointer |
Struct Pointers
With a pointer to a struct, you write p.Field instead of (*p).Field; it is dereferenced automatically.
type User struct{ Name string }
u := &User{Name: "alice"}
u.Name = "bob" // same as (*u).Name
nil
The zero value of pointers, slices, maps, channels, functions, and interfaces is nil. Dereferencing a nil pointer panics (the equivalent of C#'s NullReferenceException).
var u *User
fmt.Println(u.Name) // panic: runtime error: invalid memory address or nil pointer dereference
Check if u == nil before using a pointer that might be nil.
2. The Pass-by-Value Rule
Everything in Go is passed by value. Passing an argument to a function always makes a copy. Behavior does not change depending on whether something is a class (reference type) or a struct (value type) as in C#.
type Point struct{ X, Y int }
func moveByValue(p Point) { p.X++ } // modifies only the copy
func moveByPointer(p *Point) { p.X++ } // modifies the original
p := Point{1, 2}
moveByValue(p)
fmt.Println(p.X) // 1
moveByPointer(&p)
fmt.Println(p.X) // 2
Types That "Look Like References"
Slices, maps, channels, functions, and interfaces are small headers that contain a pointer internally. Copying them copies the header, so the underlying data is shared.
func appendOne(s []int) { s[0] = 99 } // element changes are visible to the caller
func replace(s []int) { s = []int{1, 2} } // reassigning the variable is not
m := map[string]int{}
func set(m map[string]int) { m["a"] = 1 } // writes to the map are visible
See Slices, Maps, and Arrays for details.
When to Use Pointers
- You need to modify state (e.g. pointer receivers on methods)
- You want to avoid copying a large struct
- The type contains something that must not be copied, such as
sync.Mutex - You want to represent "no value" (nil, but do not overuse it)
Conversely, small structs and basic types are fine passed by value. Using pointers increases GC load and can even be slower.
3. Stack vs. Heap and Escape Analysis
In C#, placement is determined by the kind of type: a class goes on the heap, a struct often on the stack. In Go, placement is decided not by type but by the compiler's escape analysis.
- Values not referenced from outside the function → stack (fast, no GC)
- Values that "escape" the function → heap (subject to GC)
func onStack() int {
x := 42
return x // returns a copy, so it stays on the stack
}
func onHeap() *int {
x := 42
return &x // the pointer escapes the function, so x is allocated on the heap
}
In Go, returning the address of a local variable is safe (no dangling pointers as in C/C++).
Inspecting Escape Analysis
go build -gcflags="-m" ./...
./main.go:10:2: moved to heap: x
./main.go:15:13: ... argument does not escape
Common causes of escaping to the heap:
- Returning a pointer from a function
- Assigning to an interface type (such as
any) - A closure capturing a variable and passing it out
- Slices whose size is not known at compile time / very large objects
- Sending a pointer over a channel
The goal is not to minimize heap allocations. Write it correctly first, and optimize only when benchmarks and profiles (Performance and Profiling) show a problem.
4. GC Basics and Characteristics
Go's GC is a concurrent, tri-color mark-and-sweep collector that prioritizes keeping stop-the-world (STW) pauses very short.
| Item | Go | .NET |
|---|---|---|
| Generational | No (non-generational) | Yes (Gen 0/1/2) |
| Compaction | No (does not move objects) | Yes |
| Design focus | Low latency | Throughput and flexible tuning |
| Tuning | GOGC / GOMEMLIMIT | GC modes, GCSettings, etc. |
GOGC
GOGC sets how much the live heap may grow (in %) after the previous GC before the next GC starts. The default is 100.
GOGC=200 ./app # GC less often (uses more memory)
GOGC=50 ./app # GC more often (uses less memory)
GOGC=off ./app # disable GC
GOMEMLIMIT (Go 1.19+)
Sets a soft limit on memory usage. Useful when a container has a memory cap. The GC works more aggressively as usage nears the limit.
GOMEMLIMIT=450MiB ./app # a rule of thumb for a 512MiB container limit
Practical Guidance
- Most applications are fine with the defaults
- In containers with a memory cap, setting
GOMEMLIMIThelps avoid OOM kills - If GC load is high, reducing allocations is the most effective fix (
sync.Pool, preallocating slices withmake([]T, 0, n), avoiding unnecessary pointers and string conversions) - Check GC behavior with
GODEBUG=gctrace=1,runtime/metrics, andpprof
5. Mapping to .NET
| .NET (C#) | Go |
|---|---|
| Reference types (class) / value types (struct) | Everything is pass-by-value. Use pointers when you need reference semantics |
ref / in / out | *T / &x |
Span<T> / stackalloc | Slices / automatic placement via escape analysis |
null / NullReferenceException | nil / panic on nil pointer dereference |
Nullable<T> (int?) | *int, or a zero value plus ok |
new Foo() | &Foo{} / new(Foo) |
GC.Collect() | runtime.GC() (normally not used) |
IDisposable / using | defer x.Close() |
unsafe / pointer arithmetic | unsafe.Pointer (normally not used) |
ArrayPool<T> | sync.Pool |
GCSettings.LatencyMode / GCHeapHardLimit | GOGC / GOMEMLIMIT |
6. Common Pitfalls for C# Developers
Don't Think "class or struct?"
A Go struct can be used both as a value and as a pointer. If you pass a struct around by value as though it were a C# class, it is copied unintentionally and changes are not reflected.
users := []User{{Name: "a"}}
for _, u := range users {
u.Name = "changed" // modifies only a copy
}
fmt.Println(users[0].Name) // a
for i := range users {
users[i].Name = "changed" // modify the element directly by index
}
Pointers to Loop Variables (Fixed in Go 1.22)
Up to Go 1.21, the loop variable was shared across iterations, so storing pointers to it caused a bug where all pointers referred to the same value. From Go 1.22, each iteration gets a new variable, which fixes this. Note that if the go directive in go.mod is lower than 1.22, the old behavior applies.
Return a Pointer or a Value?
- Small values or immutable data → return a value
- Stateful, large, or nil meaning "none" → return a pointer
- Express failure to obtain a required value with
(*T, error)or(T, bool)
Other Points
- Pointers to pointers (
**T) are usually unnecessary. Reconsider the design first *[]T(pointer to slice) is almost never needed. A slice is already a header; returning it is the convention- Structs containing pointers increase what the GC must scan. For large datasets, an array of values is often better
- Pointers shared across goroutines cause data races. Detect them with
go test -race - Holding the address of a slice element and then calling
appendcan leave you pointing at the old backing array after reallocation - Memory leaks do happen: unbounded maps/slices, goroutines that never stop, forgetting
Stopon atime.Ticker, etc.
7. Code Example
C#
class Point { public int X, Y; }
struct Vec { public int X, Y; }
static void MoveClass(Point p) => p.X++; // reference passed: reflected in caller
static void MoveStruct(Vec v) => v.X++; // copy: not reflected
static void MoveRef(ref Vec v) => v.X++; // ref: reflected
Go
package main
import "fmt"
type Point struct{ X, Y int }
// Everything is pass-by-value: copied regardless of type
func moveValue(p Point) { p.X++ }
// Pass a pointer to reflect changes (equivalent to C# class / ref)
func movePointer(p *Point) { p.X++ }
// Returning the address of a local is safe (it escapes to the heap)
func newPoint(x, y int) *Point {
p := Point{X: x, Y: y}
return &p
}
func main() {
p := Point{1, 2}
moveValue(p)
fmt.Println(p.X) // 1
movePointer(&p)
fmt.Println(p.X) // 2
q := newPoint(3, 4)
q.X = 10
fmt.Println(*q) // {10 4}
// nil check
var np *Point
if np == nil {
fmt.Println("it is nil, so do not dereference")
}
}
8. Summary
- Go pointers use
&/*and do not support pointer arithmetic. Dereferencing nil panics - Everything is passed by value. Use pointers to reflect changes, for large structs, and for types that must not be copied
- Slices, maps, and channels contain pointers internally, so copying them shares the data
- Whether a value lives on the stack or heap is decided by escape analysis. Check with
go build -gcflags="-m" - The GC is concurrent, non-generational, and latency-focused. Tune it with
GOGCandGOMEMLIMIT, but first prioritize reducing allocations - Optimize only after gathering evidence from profiles