Skip to main content

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
SyntaxMeaning
&xTake the address of x
*pRead/write the value pointed to by p
*TPointer 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.

ItemGo.NET
GenerationalNo (non-generational)Yes (Gen 0/1/2)
CompactionNo (does not move objects)Yes
Design focusLow latencyThroughput and flexible tuning
TuningGOGC / GOMEMLIMITGC 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 GOMEMLIMIT helps avoid OOM kills
  • If GC load is high, reducing allocations is the most effective fix (sync.Pool, preallocating slices with make([]T, 0, n), avoiding unnecessary pointers and string conversions)
  • Check GC behavior with GODEBUG=gctrace=1, runtime/metrics, and pprof

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> / stackallocSlices / automatic placement via escape analysis
null / NullReferenceExceptionnil / 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 / usingdefer x.Close()
unsafe / pointer arithmeticunsafe.Pointer (normally not used)
ArrayPool<T>sync.Pool
GCSettings.LatencyMode / GCHeapHardLimitGOGC / 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 append can leave you pointing at the old backing array after reallocation
  • Memory leaks do happen: unbounded maps/slices, goroutines that never stop, forgetting Stop on a time.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 GOGC and GOMEMLIMIT, but first prioritize reducing allocations
  • Optimize only after gathering evidence from profiles

References​