Skip to main content

Pro Tips — Pointers and Methods

Receiver Type Selection Guide

Choosing between pointer and value receivers directly impacts Go code quality. Following these guidelines will reduce mistakes.

When to Use Pointer Receivers

package main

import (
"fmt"
"sync"
)

// 1. State-mutating methods
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(item T) { // pointer receiver: state mutation
s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() (T, bool) { // pointer receiver: state mutation
if len(s.items) == 0 {
var zero T
return zero, false
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item, true
}

func (s Stack[T]) Len() int { // value receiver: read-only
return len(s.items)
}

// 2. Large structs — avoid copy cost
type LargeData struct {
data [1024]byte // 1KB
meta [128]byte
}

func (d *LargeData) Process() string { // pass as pointer, no copy
return fmt.Sprintf("processed %d bytes", len(d.data))
}

// 3. Structs containing sync primitives — MUST use pointer receiver
type SafeCounter struct {
mu sync.Mutex // Mutex must not be copied
count int
}

func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}

func (c *SafeCounter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}

func main() {
s := &Stack[int]{}
s.Push(1)
s.Push(2)
s.Push(3)
fmt.Println("stack size:", s.Len())

for {
v, ok := s.Pop()
if !ok {
break
}
fmt.Println("Pop:", v)
}

sc := &SafeCounter{}
sc.Increment()
sc.Increment()
fmt.Println("counter:", sc.Value())
}

When to Use Value Receivers

package main

import (
"fmt"
"math"
)

// 1. Small, immutable types — negligible copy cost
type Vector2D struct {
X, Y float64
}

func (v Vector2D) Length() float64 { // read-only
return math.Sqrt(v.X*v.X + v.Y*v.Y)
}

func (v Vector2D) Add(other Vector2D) Vector2D { // returns new value
return Vector2D{v.X + other.X, v.Y + other.Y}
}

func (v Vector2D) Scale(factor float64) Vector2D {
return Vector2D{v.X * factor, v.Y * factor}
}

// 2. Basic type wrappers
type Celsius float64

func (c Celsius) ToFahrenheit() float64 {
return float64(c)*9/5 + 32
}

func main() {
v1 := Vector2D{3, 4}
v2 := Vector2D{1, 2}

fmt.Println("length:", v1.Length()) // 5
fmt.Println("add:", v1.Add(v2)) // {4 6}
fmt.Println("scale:", v1.Scale(2)) // {6 8}

// Value receivers guarantee immutability in method chaining
result := v1.Scale(2).Add(v2).Scale(0.5)
fmt.Println("chained result:", result)
}

Pointers and GC

package main

import (
"fmt"
"runtime"
)

// Go's escape analysis: compiler automatically decides stack vs heap
func stackAlloc() int {
x := 42 // used without pointer → stack allocated
return x
}

func heapAlloc() *int {
x := 42 // returned as pointer → heap allocated (escapes)
return &x
}

// Reduce GC pressure: reuse structs with an object pool
type Request struct {
Method string
Path string
Body []byte
}

var requestPool = make(chan *Request, 10)

func getRequest() *Request {
select {
case r := <-requestPool:
return r // reuse from pool
default:
return &Request{}
}
}

func recycleRequest(r *Request) {
r.Method = ""
r.Path = ""
r.Body = r.Body[:0]
select {
case requestPool <- r:
default:
// pool is full — let GC handle it
}
}

func processRequest(r *Request) string {
return fmt.Sprintf("%s %s (%d bytes)", r.Method, r.Path, len(r.Body))
}

func printMemStats(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("[%s] Alloc: %d KB, NumGC: %d\n",
label, m.Alloc/1024, m.NumGC)
}

func main() {
printMemStats("start")

// Process requests using pool
for i := 0; i < 100; i++ {
r := getRequest()
r.Method = "GET"
r.Path = "/api/users"
r.Body = append(r.Body, []byte("body data")...)

result := processRequest(r)
_ = result

recycleRequest(r)
}

runtime.GC()
printMemStats("after 100 requests")
}

Interface and Pointer Pitfalls

package main

import "fmt"

type Animal interface {
Sound() string
Name() string
}

type Dog struct {
name string
}

// Implement interface with pointer receiver
func (d *Dog) Sound() string { return "woof" }
func (d *Dog) Name() string { return d.name }

type Cat struct {
name string
}

// Implement interface with value receiver
func (c Cat) Sound() string { return "meow" }
func (c Cat) Name() string { return c.name }

func describe(a Animal) {
fmt.Printf("%s says %s\n", a.Name(), a.Sound())
}

func main() {
// Pointer receiver: only *Dog implements the interface
dog := &Dog{name: "Rex"} // must be a pointer
describe(dog)

// Value receiver: both Cat and *Cat implement the interface
cat1 := Cat{name: "Whiskers"}
cat2 := &Cat{name: "Luna"}
describe(cat1) // OK
describe(cat2) // OK — Go auto-dereferences

// nil interface pitfall
var a Animal
fmt.Println(a == nil) // true

var d *Dog = nil
a = d // *Dog type but value is nil
fmt.Println(a == nil) // false! — interface holds (*Dog, nil)
// a.Sound() // panic!

// Correct nil check
if d != nil {
a = d
}
}

Functional Options — Real-World Best Practices

package main

import (
"errors"
"fmt"
"time"
)

type HTTPClient struct {
baseURL string
timeout time.Duration
maxRetries int
headers map[string]string
rateLimiter *time.Ticker
}

type ClientOption func(*HTTPClient) error

// Option functions are provided at package level — users don't touch the struct directly
func WithBaseURL(url string) ClientOption {
return func(c *HTTPClient) error {
if url == "" {
return errors.New("baseURL cannot be empty")
}
c.baseURL = url
return nil
}
}

func WithClientTimeout(d time.Duration) ClientOption {
return func(c *HTTPClient) error {
if d <= 0 {
return errors.New("timeout must be positive")
}
c.timeout = d
return nil
}
}

func WithMaxRetries(n int) ClientOption {
return func(c *HTTPClient) error {
if n < 0 {
return errors.New("maxRetries must be non-negative")
}
c.maxRetries = n
return nil
}
}

func WithHeader(key, value string) ClientOption {
return func(c *HTTPClient) error {
c.headers[key] = value
return nil
}
}

func WithRateLimit(requestsPerSecond int) ClientOption {
return func(c *HTTPClient) error {
if requestsPerSecond <= 0 {
return errors.New("requestsPerSecond must be positive")
}
interval := time.Second / time.Duration(requestsPerSecond)
c.rateLimiter = time.NewTicker(interval)
return nil
}
}

func NewHTTPClient(opts ...ClientOption) (*HTTPClient, error) {
c := &HTTPClient{
timeout: 30 * time.Second,
maxRetries: 3,
headers: make(map[string]string),
}
c.headers["Content-Type"] = "application/json"

for _, opt := range opts {
if err := opt(c); err != nil {
return nil, fmt.Errorf("client config error: %w", err)
}
}

if c.baseURL == "" {
return nil, errors.New("baseURL is required")
}

return c, nil
}

func (c *HTTPClient) Get(path string) string {
if c.rateLimiter != nil {
<-c.rateLimiter.C // rate limiting
}
return fmt.Sprintf("GET %s%s (timeout: %v, retries: %d)",
c.baseURL, path, c.timeout, c.maxRetries)
}

func main() {
client, err := NewHTTPClient(
WithBaseURL("https://api.example.com"),
WithClientTimeout(10*time.Second),
WithMaxRetries(5),
WithHeader("Authorization", "Bearer token123"),
WithHeader("X-API-Version", "2"),
)
if err != nil {
fmt.Println("Error:", err)
return
}

fmt.Println(client.Get("/users"))

// Missing required option
_, err = NewHTTPClient(
WithClientTimeout(5 * time.Second),
)
fmt.Println("Error:", err)
}

Common Pointer Mistakes and Solutions

package main

import "fmt"

// Mistake 1: Loop variable pointer
func wrongLoop() []*int {
result := make([]*int, 3)
for i := 0; i < 3; i++ {
result[i] = &i // all point to the same i!
}
return result
}

func correctLoop() []*int {
result := make([]*int, 3)
for i := 0; i < 3; i++ {
i := i // new variable i to avoid capturing loop variable
result[i] = &i
}
return result
}

// Mistake 2: nil pointer dereference
type Node struct {
Val int
Next *Node
}

func safeNext(n *Node) *Node {
if n == nil {
return nil
}
return n.Next
}

// Mistake 3: Shallow copy shares pointers
type Config struct {
Options map[string]string // map is a reference type!
}

func deepCopy(c Config) Config {
newCfg := Config{
Options: make(map[string]string, len(c.Options)),
}
for k, v := range c.Options {
newCfg.Options[k] = v
}
return newCfg
}

func main() {
// Mistake 1 demo
wrong := wrongLoop()
correct := correctLoop()
fmt.Print("wrong loop: ")
for _, p := range wrong {
fmt.Print(*p, " ") // 3 3 3 (all same)
}
fmt.Println()

fmt.Print("correct loop: ")
for _, p := range correct {
fmt.Print(*p, " ") // 0 1 2
}
fmt.Println()

// Mistake 3: shallow copy pitfall
cfg1 := Config{Options: map[string]string{"key": "value"}}
cfg2 := cfg1 // shallow copy — Options map is shared
cfg2.Options["key"] = "changed"
fmt.Println("cfg1 after shallow copy:", cfg1.Options["key"]) // changed!

cfg3 := Config{Options: map[string]string{"key": "value"}}
cfg4 := deepCopy(cfg3) // deep copy
cfg4.Options["key"] = "changed"
fmt.Println("cfg3 after deep copy:", cfg3.Options["key"]) // value (no effect)
}

Performance: Pointer vs Value — Practical Guidelines

Struct size    | Recommendation
---------------|------------------
< 64 bytes | Value pass (no pointer overhead)
64–256 bytes | Case by case
> 256 bytes | Pointer pass
package main

import "fmt"

// Small struct: value pass may be faster
type SmallPoint struct {
X, Y float64 // 16 bytes
}

func processSmall(p SmallPoint) float64 { // value pass
return p.X + p.Y
}

// Large struct: pointer pass is efficient
type LargeConfig struct {
Fields [100]string // very large struct
}

func processLarge(c *LargeConfig) int { // pointer pass
return len(c.Fields)
}

func main() {
sp := SmallPoint{1.0, 2.0}
fmt.Println(processSmall(sp))

lc := &LargeConfig{}
fmt.Println(processLarge(lc))

// Pro tip: when in doubt use pointers,
// then profile with pprof and optimize real bottlenecks
fmt.Println("Key: correct first, then fast")
}

Core Rules

  1. State-mutating methods → pointer receiver
  2. Structs with sync primitives → pointer receiver required
  3. Use the same receiver type for all methods on a type
  4. Know the method set rules for interface implementation (T only value, *T both)
  5. When storing loop variable pointers, always capture a new variable
  6. Deep copy structs containing maps or slices when needed
  7. Never confuse nil interface with nil pointer