Mutability and Data Sharing

Updated

September 13, 2026

Mutability and Data Sharing

A slice and a map are small headers that point at data. Passing them copies the header, not the elements. The boring default is: if a function should not change the caller’s data, copy first — or do not write through the header.

Mental model

A slice header is three words: pointer to an array, length, capacity. func f(s []int) copies those three words. Both headers can point at the same array. s[i] = x is visible to the caller. append is visible to the caller when the array has spare capacity; if append must grow, it may allocate a new array and then only the returned header sees the new element.

A map is already a pointer under the hood. func f(m map[int]string) copies the pointer. m[k] = v and delete(m, k) mutate the same map.

Structs and arrays are copied in full. Slices, maps, and pointers are the usual ways two functions share memory by accident.

Worked examples

Case 1: Writing through a slice header

Save as mark_paid.go. markPaid zeros the first item. order in main changes, because both headers share the array.

// mark_paid.go
package main

import "fmt"

func markPaid(cents []int) {
    if len(cents) == 0 {
        return
    }
    cents[0] = 0
}

func main() {
    order := []int{450, 120, 90}
    markPaid(order)
    fmt.Println(order)
}

Run:

go run mark_paid.go

Output:

[0 120 90]

That is useful when the function is meant to edit the order. It is a bug when the function was supposed to preview a discount.

Case 2: copy gives you a private array

Save as mark_paid_copy.go. Allocate a new slice, copy the elements, mutate the copy.

// mark_paid_copy.go
package main

import "fmt"

func markPaid(cents []int) {
    if len(cents) == 0 {
        return
    }
    cents[0] = 0
}

func main() {
    order := []int{450, 120, 90}
    safe := make([]int, len(order))
    copy(safe, order)
    markPaid(safe)
    fmt.Println("order:", order)
    fmt.Println("safe:", safe)
}

Run:

go run mark_paid_copy.go

Output:

order: [450 120 90]
safe: [0 120 90]

append([]int(nil), order...) is another way to copy. copy makes the length obvious. Either is fine. safe := order is not a copy; it copies the header only.

Case 3: Maps are shared even without a pointer in the signature

Save as close_table.go. delete inside closeTable removes Bo from main’s map.

// close_table.go
package main

import "fmt"

func closeTable(open map[int]string, n int) {
    delete(open, n)
}

func main() {
    open := map[int]string{4: "Amina", 7: "Bo", 12: "Chen"}
    closeTable(open, 7)
    fmt.Println(open)
}

Run:

go run close_table.go

Output:

map[4:Amina 12:Chen]

(fmt prints maps with keys in a stable order. Ranging a map yourself is still random.)

There is no copy for maps in the builtin set. To snapshot, allocate a new map and assign in a loop. If the function should not mutate, do not call delete or write m[k].

The trap

append on a slice that still has capacity writes into the original array. The caller’s slice length does not grow, but its elements can change.

Save as add_item.go:

// add_item.go
package main

import "fmt"

func addItem(cents []int, extra int) []int {
    return append(cents, extra)
}

func main() {
    order := make([]int, 2, 4)
    order[0], order[1] = 450, 120
    next := addItem(order, 90)
    next[0] = 1
    fmt.Println("order:", order)
    fmt.Println("next:", next)
}

Run:

go run add_item.go

Output:

order: [1 120]
next: [1 120 90]

order never got a third element, but next[0] = 1 overwrote order[0]. Spare capacity made the headers aliases.

The fix is to copy into a new array before append, so growth cannot land in the caller’s backing store:

// add_item_copy.go
package main

import "fmt"

func addItem(cents []int, extra int) []int {
    out := make([]int, len(cents), len(cents)+1)
    copy(out, cents)
    return append(out, extra)
}

func main() {
    order := make([]int, 2, 4)
    order[0], order[1] = 450, 120
    next := addItem(order, 90)
    next[0] = 1
    fmt.Println("order:", order)
    fmt.Println("next:", next)
}

Run:

go run add_item_copy.go

Output:

order: [450 120]
next: [1 120 90]

If addItem is supposed to share — a builder that owns the buffer — return the new header and stop using the old one. The bug is using both.

The boring rule

  • Slice and map parameters are shared storage unless you copy.
  • s2 := s copies the header. copy (or append onto a fresh slice) copies elements.
  • After s = append(s, x), treat s as the only header you still use.
  • Name functions after mutation: markPaid, closeTable. If the name is preview, copy inside.
  • Do not clone maps and slices “just in case” on every call. Clone at the boundary where ownership splits.

Try this

  1. In mark_paid.go, replace cents[0] = 0 with cents = append(cents, 0) and print order in main. Length in main does not change (the header in main was not updated).
  2. In mark_paid_copy.go, write safe := order instead of make+copy. Confirm order is mutated again.
  3. In close_table.go, add open[4] = "Dana" inside closeTable. Confirm main sees Dana.
  4. Change add_item.go so order is built with []int{450, 120} (length 2, capacity 2). append must allocate. See whether next[0] = 1 still changes order.