Skip to Content
sudocs

Structs

Creating structs, value vs pointer receivers, what counts as a copy, and the range trap.

Creating a struct

A struct type is a blueprint. Nothing exists in memory until you create a value from it.

type Account struct {
	Owner   string
	Balance float64
}

a1 := &Account{Owner: "Suprim", Balance: 1000} // named fields (preferred)
a2 := &Account{"Ram", 500}                     // positional: must match field order
var a3 Account                                 // zero value: {"" 0}
a3.Owner = "Sita"                              // then set fields directly
Printing
fmt.Println(a1)         // &{Suprim 1000}               & means it's a pointer
fmt.Printf("%+v\n", a1) // &{Owner:Suprim Balance:1000} %+v adds field names
fmt.Println(a3)         // {Sita 0}                     no &, it's a value

Use named fields

Positional literals break silently if a field is added or the order changes. Use named fields unless the struct is tiny and local.

Value vs pointer receivers

Whether a method changes the original depends only on the method's receiver. It doesn't matter whether your variable is a value or a pointer, because Go converts between them for you.

Your variableMethod receiverWhat Go actually doesOriginal changes?
a3 (value)Deposit (*Account)(&a3).Deposit(...)Yes
a1 (pointer)BrokenDeposit (Account)(*a1).BrokenDeposit(...)No, works on a copy
func (a Account) BrokenDeposit(amt float64) { a.Balance += amt } // a is a copy: change is lost
func (a *Account) Deposit(amt float64)      { a.Balance += amt } // a points to the real struct
  • Setting fields directly (a3.Owner = "Sita") always changes the variable itself. Receivers only matter inside a method.
  • A method that only reads shows current values with either receiver.

When to use which

Use a pointer receiver when:

  • the method changes the struct,
  • the struct is big (a value receiver copies the whole thing on every call),
  • it holds something that must not be copied, like a sync.Mutex,
  • the type's other methods already use pointers. Don't mix receiver types on one type.

Use a value receiver for small, read-only methods on types where no method needs a pointer.

What counts as a copy

var a3 Account is a new, standalone value. It isn't a copy of anything. A copy only happens when an existing value is duplicated:

b := a3                                // b is a copy of a3
accounts := []Account{*a1, *a2, a3}    // each element is a copy
for _, acc := range accounts { }       // acc is a copy of each element
func (a Account) BrokenDeposit(...)    // a is a copy of what it was called on

Copying a pointer doesn't copy the struct

A copied address still points to the same struct. This is why a slice of pointers works in the trap below.

The range trap

accounts := []Account{*a1, *a2, a3} // copies of the accounts
for _, acc := range accounts {
	acc.Deposit(100) // changes acc, a copy of a copy. Nothing outside the loop changes.
}

Two ways to fix it, and they do different things:

// range still copies, but it copies the POINTER, which points to the same struct.
// Changes a1, a2 and a3.
accounts := []*Account{a1, a2, &a3}
for _, acc := range accounts {
	acc.Deposit(100)
}

In the real world

A tiny HTTP API that takes deposits, like a handler you'd write with Gin. Everything from this page shows up in it:

main.go
package main

import (
	"encoding/json"
	"log"
	"net/http"
	"sync"
)

// What the client sends. Struct tags map JSON keys to fields.
type DepositRequest struct {
	Owner  string `json:"owner"`
	Amount int64  `json:"amount"` // cents
}

// One Store is shared by every request.
type Store struct {
	mu       sync.Mutex
	balances map[string]int64
}

// The zero value isn't usable: its map is nil, and writing to a nil map panics.
func NewStore() *Store {
	return &Store{balances: make(map[string]int64)}
}

// Pointer receiver: every request must lock the SAME mutex.
func (s *Store) Deposit(owner string, amount int64) int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.balances[owner] += amount
	return s.balances[owner]
}

func (s *Store) handleDeposit(w http.ResponseWriter, r *http.Request) {
	var req DepositRequest
	// &req: Decode has to write into our variable, not into a copy of it.
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Amount <= 0 {
		http.Error(w, "invalid request", http.StatusBadRequest)
		return
	}
	balance := s.Deposit(req.Owner, req.Amount)
	json.NewEncoder(w).Encode(map[string]int64{"balance": balance})
}

func main() {
	store := NewStore()
	http.HandleFunc("POST /deposit", store.handleDeposit)
	log.Fatal(http.ListenAndServe(":8089", nil))
}
Try it
go run main.go
curl -X POST localhost:8089/deposit -d '{"owner":"suprim","amount":50000}'
# {"balance":50000}

Where the struct rules matter:

  1. Decode(&req) takes a pointer. Decode has to write into your variable. Passing req would hand it a copy, and your req would stay empty. This is the same rule as Transfer(a1, &a3, 200).
  2. Store needs pointer receivers. Every request shares one Store, so every request must lock the same mutex.
  3. The zero value isn't always usable. var s Store has a nil map, and writing to a nil map panics. That's why NewStore() exists: a constructor is the usual Go answer when the zero value isn't ready to use.
  4. Struct tags (`json:"owner"`) tell encoding/json which JSON key goes in which field.

The bug you'd hit

Change Deposit to a value receiver, func (s Store) Deposit(...). Each call now locks its own copy of the mutex, so nothing is locked at all. The map isn't copied, though: both copies point to the same map. Under load the server crashed with fatal error: concurrent map writes. go vet catches it before you run anything: Deposit passes lock by value: Store contains sync.Mutex.

My doubts

"I declared var config ServerConfig as a value. Why did setting its fields and calling Display() show my values?" var creates a variable, not a receiver. Writing to its fields changes it. Display only reads, so it shows the current values with either receiver type.

"Is a3 a copy of the original struct?" There is no original struct. The type is a blueprint, and a3 is its own value.

"Why did acc.Summary() skip a2?" It didn't skip anything. It printed nothing at all. Summary() returns a string, and the lines I saw came from earlier Println calls.

Mistakes I made

What I thoughtWhat's actually trueFix
a3.Deposit(2000) works on a copy because a3 is a valueGo calls (&a3).Deposit, so a3 changesLook at the receiver, not the variable
Passing &a3 to Transfer changes "a copy"&a3 is the address of a3 itself
The bonus loop changed a1 and a2Nothing changed: the slice literal and range both copy[]*Account, or loop by index
acc.Summary() printsIt returns a string, which was thrown awayfmt.Println(acc.Summary())
Withdraw with a.Balance <= amountRefuses to withdraw the exact balanceUse <
Println shows &{"Suprim" 1000}%v prints strings without quotes: &{Suprim 1000}

Practice problem: bank account

  1. Define Account with Owner string and Balance float64.
  2. Create three accounts: named fields, positional fields, and a zero value. Print each with Println and %+v.
  3. Write Summary() string, Deposit(amount) and Withdraw(amount) bool, choosing a receiver for each.
  4. Write BrokenDeposit with the other receiver type. Predict the output, then run it.
  5. Write Transfer(from, to *Account, amount float64) bool. Send 200 from Suprim to Sita.
  6. Bonus: put the accounts in a slice, range over it and deposit 100 into each. Did the balances change? Why?

My solution vs best practice

revision/structs/improved/main.go
// Best-practice version of ../main.go (bank account problem).
// Run: go run ./revision/structs/improved   (from ~/projects/go)
//
// What changed vs my solution, and why:
//  1. Money is int64 cents, not float64. Floats can't store most decimals exactly
//     (0.1 + 0.2 != 0.3), so balances drift. Integer cents are always exact.
//  2. All methods use pointer receivers. Once one method needs a pointer, Go style
//     is to use pointers for all of them so the type behaves one consistent way.
//  3. Errors instead of bool. Withdraw/Transfer return an error that says WHY it
//     failed (insufficient funds vs invalid amount). The caller can check which with
//     errors.Is.
//  4. Amounts are validated: depositing -500 would otherwise be a hidden withdrawal.
//  5. No `if a == nil` checks. Calling a method on a nil *Account is a programmer bug;
//     silently returning hides it. Let it panic so you find the bug.
//  6. Transfer reuses Withdraw and Deposit instead of repeating the balance logic.
//  7. Summary returns a string with no trailing "\n"; the caller decides on newlines.
//  8. Always use named fields in literals. Positional ones break silently if a field
//     is added or the order changes.
package main

import (
	"errors"
	"fmt"
)

var (
	ErrInvalidAmount     = errors.New("amount must be positive")
	ErrInsufficientFunds = errors.New("insufficient funds")
)

type Account struct {
	Owner   string
	Balance int64 // in cents: 1000_00 = $1000.00
}

func (a *Account) Summary() string {
	return fmt.Sprintf("%s has $%d.%02d", a.Owner, a.Balance/100, a.Balance%100)
}

func (a *Account) Deposit(amount int64) error {
	if amount <= 0 {
		return ErrInvalidAmount
	}
	a.Balance += amount
	return nil
}

func (a *Account) Withdraw(amount int64) error {
	if amount <= 0 {
		return ErrInvalidAmount
	}
	if a.Balance < amount {
		return ErrInsufficientFunds
	}
	a.Balance -= amount
	return nil
}

// Transfer is a plain function: it belongs to neither account more than the other.
func Transfer(from, to *Account, amount int64) error {
	if err := from.Withdraw(amount); err != nil {
		return fmt.Errorf("transfer from %s: %w", from.Owner, err)
	}
	// Can't fail: Withdraw already rejected amount <= 0.
	_ = to.Deposit(amount)
	return nil
}

func main() {
	suprim := &Account{Owner: "Suprim", Balance: 1000_00}
	ram := &Account{Owner: "Ram", Balance: 500_00}
	var sita Account // zero value: {"" 0}
	sita.Owner = "Sita"

	fmt.Printf("%+v\n", *suprim) // {Owner:Suprim Balance:100000}

	if err := Transfer(suprim, &sita, 200_00); err != nil {
		fmt.Println("error:", err)
	}
	fmt.Println(suprim.Summary()) // Suprim has $800.00
	fmt.Println(sita.Summary())   // Sita has $200.00

	// The error says why it failed, and errors.Is can check the reason
	// even though Transfer wrapped it with %w.
	err := Transfer(ram, &sita, 9999_00)
	fmt.Println(err)                                  // transfer from Ram: insufficient funds
	fmt.Println(errors.Is(err, ErrInsufficientFunds)) // true

	// Slice of pointers: range copies each pointer, but the copy points to the same
	// account, so the originals change.
	accounts := []*Account{suprim, ram, &sita}
	for _, acc := range accounts {
		_ = acc.Deposit(100_00) // safe to ignore: 100_00 is a valid amount
	}
	for _, acc := range accounts {
		fmt.Println(acc.Summary()) // $900.00, $600.00, $300.00
	}
}

What changed and why

  1. Money is int64 cents, not float64. Floats can't store most decimals exactly (0.1 + 0.2 != 0.3), so balances drift. Integer cents are always exact.
  2. All methods use pointer receivers. Once one method needs a pointer, use pointers for all of them.
  3. Errors instead of bool. Withdraw and Transfer return an error that says why they failed. The caller can check the reason with errors.Is, even after wrapping with %w.
  4. Amounts are validated. Without it, depositing -500 is a hidden withdrawal.
  5. No if a == nil checks. Calling a method on a nil pointer is a bug. Returning quietly hides it, and a panic shows you where it is.
  6. Transfer reuses Withdraw and Deposit instead of repeating the balance logic.
  7. Summary has no trailing \n. The caller decides about newlines.
  8. Named fields everywhere.

Official docs

On this page