Logo
Logo

Atharva Pandey/Lesson 7: Values Copy vs Share — When Go copies and when it doesn't

Created Sat, 01 Mar 2025 00:00:00 +0000 Modified Sat, 01 Mar 2025 00:00:00 +0000

Early in my Go career I had a bug where I modified a slice inside a function expecting the caller’s slice to remain unchanged — and it did. Then I had a different bug where I expected the opposite and the caller’s slice was modified. I had no mental model for predicting which would happen. Building that model is what this lesson is about.

The Problem

Go passes everything by value. That’s the official line, and it’s true. But “by value” means different things for different types:

  • For an int, passing by value copies the integer — completely independent.
  • For a slice, passing by value copies the slice header (pointer + length + capacity) — but the underlying array is shared.
  • For a map, passing by value copies the map pointer — both the original and the copy point to the same hash table.
  • For a pointer, passing by value copies the pointer — both point to the same underlying data.

The confusion arises because Go has both value types (copy semantics) and reference types (shared backing storage), and they look similar at the call site. Understanding which is which — and why — gives you a reliable mental model.

The Idiomatic Way

Let’s walk through each category explicitly:

package main

import "fmt"

// Integers, floats, bools: pure value types
// Assignment and function calls create independent copies
func incrementInt(n int) int {
    n++
    return n
}

// Arrays (not slices): value types — fully copied
func zeroFirstElement(arr [5]int) {
    arr[0] = 0 // modifies the local copy only
}

// Structs without reference fields: value types — fully copied
type Point struct{ X, Y float64 }

func movePoint(p Point, dx, dy float64) Point {
    p.X += dx
    p.Y += dy
    return p // returning modified copy
}

func main() {
    n := 10
    incremented := incrementInt(n)
    fmt.Println(n, incremented) // 10 11 — n unchanged

    arr := [5]int{1, 2, 3, 4, 5}
    zeroFirstElement(arr)
    fmt.Println(arr[0]) // 1 — arr unchanged

    p := Point{X: 1.0, Y: 2.0}
    moved := movePoint(p, 1.0, 1.0)
    fmt.Println(p, moved) // {1 2} {2 3} — p unchanged
}

Now the reference-like types:

package main

import "fmt"

// Slices: header is copied, backing array is shared
func appendToSlice(s []int, v int) []int {
    // This does NOT modify the caller's length/capacity
    // but it DOES modify the backing array if len < cap
    s = append(s, v)
    return s
}

func modifyElement(s []int, idx, val int) {
    s[idx] = val // THIS modifies the caller's backing array!
}

func main() {
    original := make([]int, 3, 6) // len=3, cap=6
    original[0], original[1], original[2] = 10, 20, 30

    // modifyElement shares the backing array — caller sees the change
    modifyElement(original, 0, 999)
    fmt.Println(original[0]) // 999 — modified!

    // appendToSlice: if within capacity, modifies backing array
    // but returns a slice with different length
    original[0] = 10 // reset
    result := appendToSlice(original, 40)
    fmt.Println(original) // [10 20 30] — caller's LENGTH unchanged
    fmt.Println(result)   // [10 20 30 40] — new view of same array

    // If backing array is accessed via original past index 3:
    // original[:4] would show [10 20 30 40] — the element was written!
    fmt.Println(original[:4]) // [10 20 30 40] — sharing confirmed
}

The slice rule is the most nuanced. A slice has three parts: a pointer to the backing array, a length, and a capacity. When you pass a slice to a function, the function gets its own copy of these three values. Modifications to elements within the length affect the shared backing array. But changes to length or capacity (via append) don’t affect the caller’s slice header.

package main

import "fmt"

// Maps: always share the underlying hash table
func addToMap(m map[string]int, key string, val int) {
    m[key] = val // modifies caller's map — there's no "copy" here
}

func replaceMap(m map[string]int) {
    // This only replaces the local copy of the map pointer
    // does NOT affect the caller's map variable
    m = make(map[string]int)
    m["new"] = 1
}

func main() {
    myMap := map[string]int{"a": 1, "b": 2}

    addToMap(myMap, "c", 3)
    fmt.Println(myMap) // map[a:1 b:2 c:3] — modified!

    replaceMap(myMap)
    fmt.Println(myMap) // map[a:1 b:2 c:3] — unchanged!
    // replaceMap created a new map and pointed its local variable at it
    // the caller's variable still points to the original map
}

In The Wild

This copy-vs-share distinction appears constantly in idiomatic Go patterns. One of the most common is the functional options pattern, where you want to build a config struct by value:

package main

import (
    "fmt"
    "time"
)

type ServerConfig struct {
    Host        string
    Port        int
    Timeout     time.Duration
    MaxConns    int
    TLSEnabled  bool
}

type Option func(*ServerConfig)

func WithTimeout(d time.Duration) Option {
    return func(c *ServerConfig) {
        c.Timeout = d // modifies via pointer — shares the config being built
    }
}

func WithMaxConns(n int) Option {
    return func(c *ServerConfig) {
        c.MaxConns = n
    }
}

func NewServer(opts ...Option) ServerConfig {
    cfg := ServerConfig{
        Host:     "localhost",
        Port:     8080,
        Timeout:  30 * time.Second,
        MaxConns: 100,
    }
    for _, opt := range opts {
        opt(&cfg) // pass pointer so options can modify cfg
    }
    return cfg // return by value — caller gets their own copy
}

func main() {
    cfg := NewServer(
        WithTimeout(10*time.Second),
        WithMaxConns(500),
    )
    fmt.Printf("%+v\n", cfg)
    // Mutations inside opts affected cfg via pointer
    // but the returned cfg is an independent value copy
}

Another real case: copying slices when you need true independence:

package main

import "fmt"

// When you NEED a true copy of a slice — use copy()
func safeProcess(data []int) []int {
    // If we might modify elements and don't want to affect the caller:
    local := make([]int, len(data))
    copy(local, data)

    for i := range local {
        local[i] *= 2
    }
    return local
}

// Contrast with in-place modification (intentional sharing):
func doubleInPlace(data []int) {
    for i := range data {
        data[i] *= 2
    }
}

func main() {
    original := []int{1, 2, 3, 4, 5}

    doubled := safeProcess(original)
    fmt.Println(original) // [1 2 3 4 5] — unchanged
    fmt.Println(doubled)  // [2 4 6 8 10]

    doubleInPlace(original)
    fmt.Println(original) // [2 4 6 8 10] — modified in place
}

The Gotchas

Struct with a slice field: a struct is a value type — but if it contains a slice field, copying the struct copies the slice header, and both the original and the copy share the backing array:

package main

import "fmt"

type Buffer struct {
    data []byte
    pos  int
}

func main() {
    b1 := Buffer{data: []byte("hello"), pos: 0}
    b2 := b1 // struct copy — SHALLOW copy

    b2.pos = 3           // independent: modifies only b2's pos
    b2.data[0] = 'H'     // shared: modifies the backing array both see

    fmt.Println(string(b1.data)) // "Hello" — b1's array was modified!
    fmt.Println(b1.pos)          // 0 — b1's pos unchanged
    fmt.Println(b2.pos)          // 3 — b2's pos is independent
}

To get a truly independent copy, you must deep-copy the slice explicitly. This is why types containing slices often implement a Clone() method.

Range loop copies elements: the range loop gives you a copy of each element. Modifying the range variable does not modify the slice:

package main

import "fmt"

type Item struct{ value int }

func main() {
    items := []Item{{1}, {2}, {3}}

    // WRONG: modifies a copy of each Item
    for _, item := range items {
        item.value *= 10 // no effect on items
    }
    fmt.Println(items) // [{1} {2} {3}]

    // CORRECT: modify via index
    for i := range items {
        items[i].value *= 10
    }
    fmt.Println(items) // [{10} {20} {30}]

    // Also correct: slice of pointers
    ptrs := []*Item{{1}, {2}, {3}}
    for _, item := range ptrs {
        item.value *= 10 // modifies via pointer — works
    }
    fmt.Println(*ptrs[0]) // {10}
}

Channel sends copy the value: when you send a value on a channel, Go copies it. This is intentional — it prevents races. But it means large structs are expensive to send; send pointers instead for large values (while being mindful of ownership and the GC implications).

Key Takeaway

Go is always pass-by-value, but “value” means different things for different types. Integers, floats, bools, arrays, and structs without reference fields are true value types — assignments and function calls create fully independent copies. Slices, maps, channels, pointers, and functions contain or are pointers to shared backing storage — copying these gives you a new header that still refers to the same underlying data.

The practical rules:

  • Modifying slice elements inside a function affects the caller; modifying length/capacity (via append) does not
  • Map operations always affect the shared hash table; reassigning the map variable does not
  • Use copy() when you need a truly independent slice
  • Structs containing slices or maps are shallow-copied — implement Clone() when deep copying is needed
  • In range loops, modify via index (items[i]), not via the range variable, for slice element modification

Series: Go Memory Model & Internals

← Lesson 6: Memory Alignment and Struct Padding | Lesson 8: String Internals →