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