You're writing Go for a while. You use struct for data, slice for lists, map for dictionaries. It works. But as the project grows — state management, shared APIs, concurrency — trouble starts: unexpected mutations, interfaces that don't fit, types that break. The problem isn't Go: it's not understanding the memory model and the contract of each type.
At Meteora Web, we've been using Go for high-performance backends for years. We've seen code where passing a slice by value crashed the server, or a shared map between goroutines triggered data races. This guide is not a beginner's recap: it's a hands-on deep dive into how struct, interface, slice, map, and pointer really work, with examples you can compile and test right away.
Go's Memory Model: Value vs Reference
Before any type, you need to grasp one thing: in Go almost everything is passed by value. When you assign a variable to another or pass it to a function, Go copies the value. Sounds trivial, but it's the cause of 80% of errors.
The exceptions are types with implicit references: slice, map, channel, pointer (obviously), and interface. When you copy a slice or a map, you copy the data structure that points to the actual data, not the data itself. Two variables can share the same underlying array. This is powerful, but dangerous if you don't control it.
Struct: Aggregate Data, But Beware of Copy
A struct is a value type. Passing it to a function makes a complete copy — all fields, bytes. Fine for small structs. For structs with internal slices or large fields, use a pointer.
type Person struct {
Name string
Age int
}
func birthday(p Person) {
p.Age++
}
func main() {
p := Person{Name: "Mario", Age: 30}
birthday(p)
fmt.Println(p.Age) // 30, not 31!
}
Solution: pass a pointer.
func birthday(p *Person) { p.Age++ }Methods on Struct: Value vs Pointer Receiver
Methods in Go can have a value or pointer receiver. The choice changes everything: a value receiver copies the struct; a pointer one does not. Rule of thumb: if the method modifies state, use pointer receiver. For small structs (e.g.,
time.Duration), value is fine.func (p *Person) HaveBirthday() { p.Age++ } func (p Person) IsAdult() bool { return p.Age >= 18 }Slice: The Hidden Structure
A
sliceis not an array. It's a three-field struct: pointer to underlying array, length, and capacity. When you pass a slice to a function, you pass that triplet (by value). But the pointer to the array remains: if you modify elements, you modify the shared array.Classic mistake:
func modifySlice(s []int) { for i := range s { s[i] *= 2 } s = append(s, 99) // does not modify original slice! } func main() { a := []int{1, 2, 3} modifySlice(a) fmt.Println(a) // [2 4 6] — not [2 4 6 99] }To modify the slice (add/remove), return it or pass a pointer to slice.
Slice Capacity and Append
When you
append, if capacity is enough, the slice writes to the same memory area. Otherwise it allocates a new one (and doubles capacity). Knowing this avoids unnecessary allocations.s := make([]int, 0, 10) // capacity 10 for i := 0; i < 10; i++ { s = append(s, i) // no allocations }Map: Reference Type to Handle with Care
A
mapin Go is a pointer to an internal structure. Passing it by value equals passing a pointer: if you modify the content, both sides see it. But if you assignnilto one variable, the other doesn't know.func addEntry(m map[string]int, key string, val int) { m[key] = val } func main() { m := map[string]int{"a": 1} addEntry(m, "b", 2) fmt.Println(m) // map[a:1 b:2] }Warning: maps are not thread-safe. If two goroutines write to the same map without synchronization, you get fatal error: concurrent map writes. Use
sync.Mutexorsync.Map.Map and Pointers: Beware of Copy
When a map has struct values, getting
v := m[key]copies the struct. Modifyingvdoes not update the map. Solution: either use pointer values or reassign.type Counter struct{ Value int } func main() { m := map[string]*Counter{} m["page"] = &Counter{} m["page"].Value++ // ok because it's a pointer }Pointer: Explicit Control
Pointers in Go are safe (no pointer arithmetic like C), but must be used with awareness. A pointer can be
nil. Dereferencing a nil pointer causes a panic.Use pointers when:
- You need to modify a value from a function (e.g.,
Scan(&x)) - The data structure is large and you want to avoid copies
- You need to represent absence of a value (e.g.,
var p *int = nil)
Don't use pointers for small immutable values (int, bool). Pointers add unnecessary complexity and increase garbage collector pressure.
Interface: The Implicit Contract
An interface in Go is a type that holds two pointers: the dynamic type and the dynamic value. When you assign a value to an interface, Go stores the type and a copy of the value (or a pointer if the value is a pointer).
Interfaces are satisfied implicitly: if a type implements the declared methods, it automatically implements the interface. This is flexible but can lead to subtle errors.
Nil is Not Always Nil
An interface variable is nil only if both type and value are nil. If you assign a nil pointer to an interface, the interface is not nil — it has a type (*Person) and a nil value. This breaks nil checks.
var p *Person = nil
var i interface{} = p
fmt.Println(i == nil) // false!
Moral: don't check err != nil if err might be an interface wrapping a nil value. Better to return explicit nil for the interface.
Empty Interface and Type Assertion
Empty interface interface{} accepts any type. Don't use it as wild dynamic typing — Go is not JavaScript. For heterogeneous data, use any (alias in Go 1.18+) and type switches.
func detect(v any) {
switch val := v.(type) {
case int:
fmt.Println("int:", val)
case string:
fmt.Println("string:", val)
default:
fmt.Println("unknown")
}
}
When to Use Pointers and When Values in Compound Types
No one-size-fits-all rule. At Meteora Web we follow these guidelines:
- Structs representing entities with identity (e.g., user, order): use pointers or always use pointer (
*User). - Small immutable structs (e.g., coordinates, color): pass by value.
- Slice of structs: if you need to modify elements in-place, use
[]*T. Otherwise[]Tis more cache-friendly. - Maps of structs: almost always
map[K]*Vto avoid copies. - Interfaces: avoid storing pointers to interfaces. Interfaces are already pointers to the actual value.
What To Do Next
- Review your Go code — look for passing slices and maps to functions, and ask: “what happens if I modify here?”.
- Test the difference between value and pointer receiver on a medium-sized struct (e.g., 20 fields). Compare allocations with
go test -benchmem. - Apply type assertions cautiously: never do
v.(T)without the second boolean, or use aswitch. - Design interfaces for behavior, not data. A single-method interface (
Reader,Writer) is gold. - Mutex shared maps between goroutines. Or better, use
sync.Mapfor specific cases (heavy reads, rare writes).
We at Meteora Web have seen Go codebases turn into nightmares when these mechanisms are ignored. But with the right awareness, they become incredibly powerful tools. Happy coding.
Sponsored Protocol