DEV Community

Ajisafe Victor Oluwapelumi
Ajisafe Victor Oluwapelumi

Posted on

Understanding Value and Pointer Receivers in Go: Building a Crypto Tracker

I picked up Go recently while building the backend of a crypto tracker. This tracker allows users to manage their cryptocurrency holdings and calculate their wallet value in real-time. One of my favorite learnings so far is value receivers and pointer receivers, and how everything works under the hood.

How Go Passes Arguments

Go passes arguments by value, not by reference. This means it creates a copy of the argument and passes that copy to the function. Understanding this is crucial to writing efficient Go code.

Value Receivers: Working with Copies

When you use a value receiver, Go creates a copy of the struct. Any modifications you make inside the function only affect the copy, not the original.

type Wallet struct {
    User       string
    TotalValue float64
}

// Value receiver - receives a copy
func (w Wallet) UpdateValue(newValue float64) {
    w.TotalValue = newValue  // Only modifies the copy
    fmt.Println("Inside function:", w.TotalValue)
}

func main() {
    wallet := Wallet{User: "pelumi", TotalValue: 1000.0}
    wallet.UpdateValue(5000.0)

    fmt.Println("Outside function:", wallet.TotalValue)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Inside function: 5000
Outside function: 1000
Enter fullscreen mode Exit fullscreen mode

The original wallet.TotalValue remains 1000 because we modified a copy. When the function returns, the copy is discarded (removed from the stack), and our changes are lost.

Pointer Receivers: Modifying the Original

Pointer receivers solve this by passing the memory address of the struct instead of a copy. When you pass an address, Go still passes it by value, but now you have an address that points to the original data.

// Pointer receiver - receives a pointer (address)
func (w *Wallet) UpdateValue(newValue float64) {
    w.TotalValue = newValue  // Modifies the original
    fmt.Println("Inside function:", w.TotalValue)
}

func main() {
    wallet := Wallet{User: "pelumi", TotalValue: 1000.0}
    wallet.UpdateValue(5000.0)  // Go automatically passes &wallet

    fmt.Println("Outside function:", wallet.TotalValue)
}
Enter fullscreen mode Exit fullscreen mode

Output:

Inside function: 5000
Outside function: 5000
Enter fullscreen mode Exit fullscreen mode

Now both print 5000 because we modified the original through its address. Go automatically handles the dereferencing (you don't need to write (*w).TotalValue), making the syntax clean.

Real-World Example: My Crypto Tracker

In my crypto tracker, I use pointer receivers when updating wallet data:

type Wallet struct {
    ID         string
    User       string
    TotalValue float64
    Holdings   []Holding
    UpdatedAt  time.Time
}

// Pointer receiver because we need to modify the wallet
func (w *Wallet) CalculateTotalValue(priceService *PriceService) error {
    total := 0.0

    for _, holding := range w.Holdings {
        price, err := priceService.FetchCoinPrice(holding.Coin)
        if err != nil {
            return err
        }
        total += price * holding.Amount
    }

    w.TotalValue = total      // Modifies the original wallet
    w.UpdatedAt = time.Now()  // Updates timestamp
    return nil
}
Enter fullscreen mode Exit fullscreen mode

If I used a value receiver here, the calculated TotalValue and UpdatedAt would never be saved to the original wallet.

The Stack vs Heap Trade-off

Here's where it gets interesting. Value receivers are faster because they use the stack, but pointer receivers can be slower because they may require the heap.

Stack (Fast)

When you use value receivers, Go allocates the copy on the stack. The stack is fast because:

  • Memory is automatically managed (allocated and freed when functions return)
  • Memory is local and cache-friendly
  • No garbage collector involvement
func (w Wallet) GetUser() string {
    return w.User  // Fast: uses stack memory
}
Enter fullscreen mode Exit fullscreen mode

Heap (Slower, but Necessary)

When you use pointer receivers, Go might allocate the variable on the heap if it "escapes" the function scope. This happens because the address could be referenced later in the program, so Go can't safely discard it when the function returns.

func (w *Wallet) UpdateAndReturn() *Wallet {
    w.TotalValue = 10000
    return w  // Address escapes, so w lives on the heap
}
Enter fullscreen mode Exit fullscreen mode

The heap is slower because:

  • The garbage collector must track and clean up these allocations
  • Memory access is less cache-friendly
  • Allocation and deallocation take more time

However, this trade-off is usually worth it when you need to modify large structs or avoid expensive copies.

When to Use Each

Use Value Receivers When:

  • You don't need to modify the struct
  • The struct is small (a few fields)
  • You want maximum performance for read-only operations
func (w Wallet) IsEmpty() bool {
    return len(w.Holdings) == 0  // Just reading, no modifications
}

func (w Wallet) GetUserInfo() string {
    return fmt.Sprintf("User: %s, Value: $%.2f", w.User, w.TotalValue)
}
Enter fullscreen mode Exit fullscreen mode

Use Pointer Receivers When:

  • You need to modify the struct
  • The struct is large (copying would be expensive)
  • You want consistency (if some methods use pointers, use them everywhere)
func (w *Wallet) AddHolding(holding Holding) {
    w.Holdings = append(w.Holdings, holding)  // Modifies original
}

func (w *Wallet) ClearHoldings() {
    w.Holdings = []Holding{}  // Modifies original
}
Enter fullscreen mode Exit fullscreen mode

A Common Pattern: Mixing Both

In practice, if you have any pointer receiver methods, it's best to use pointer receivers for all methods on that type for consistency:

type Wallet struct {
    User       string
    TotalValue float64
}

// Even though this doesn't modify, use pointer for consistency
func (w *Wallet) GetUser() string {
    return w.User
}

// This modifies, so it needs a pointer
func (w *Wallet) SetUser(user string) {
    w.User = user
}
Enter fullscreen mode Exit fullscreen mode

What I've Learned

Coming from C, I understood pointers conceptually, but Go's implementation adds an interesting layer. The automatic dereferencing makes pointer receivers feel natural to use, and the compiler's escape analysis intelligently decides when to use the heap vs stack.

The key insight: value receivers are best when you don't intend to modify the argument, while pointer receivers are perfect when you want to modify a passed argument or when you want to avoid copying large values.

Top comments (0)