Skip to content

Why Go's append Changes the Original — Inside Slices, Maps, and Strings

Contents8 sections

When I listed the Go bugs I run into most, all three had the same shape: what I thought I was holding wasn’t what I was actually holding. A slice is a window, not data. A map doesn’t guarantee addresses. A string isn’t a character array. So I opened up the layout of each one.

Arrays and slices: a value or a window

Go arrays aren’t like arrays in other languages. They’re value types, and the length is part of the type. [5]int and [4]int are different types, so you can’t pass one where the other is expected. Assigning an array copies every element.

So in practice you rarely use arrays directly. You use slices instead, and a slice doesn’t hold data. It’s a window onto part of a backing array. The whole window is one pointer and two integers: a three-word header.

Array [5]int · a value, copied whole on assignmenta12345b12345b := a → copies all fiveb[0] = 99 leaves a alonelength is part of the typeSlice b := a[1:3] · a 3-word header (24 bytes)ptraddresslen 2visiblecap 4to the end1[0]2[1]3[2]4[3]5[4]backing array alen 2 · the visible partcap 4 · from start to end of arraycap − len = 2 → two appends reuse the same array
A slice is a three-word header pointing into part of a backing array

Two things that tripped me up

Even after drawing that picture, I spent a long time confused. Looking back, I had two questions, and both came from the wrong mental model that a slice is an upgraded array.

① Is a slice just a [4]int with len and cap attached? No. It’s closer to the opposite. Arrays and slices are completely different types, and a slice isn’t an array with extra fields. It’s a separate little struct that points at an array. An array is the data itself; a slice is more like a note saying where to find the data.

An array variable and a slice variable
// array: the variable is the data. There's no pointer involved
var arr [4]int = [4]int{1, 2, 3, 4} // arr is the memory for 4 ints itself
// one slice literal actually creates two things
s := []int{1, 2, 3, 4}
// ① an underlying array somewhere holding {1,2,3,4}
// ② in s, a {ptr, len, cap} header pointing at that array

It’s true that there’s always an array behind a slice. Even without an array literal, as in make([]int, 4), an array gets created inside. You just don’t see it, and the slice still isn’t an extension of it.

② So is the array the ptr? Not that either. The array isn’t ptr; it’s what ptr points to. That sounds subtle, but miss it and the memory leak later on won’t make sense.

Passing to a function: by value or by reference?

This comes up in interviews a lot, and answering with just one of the two is half wrong. To be precise, the header is copied by value, and the data is shared. That produces an asymmetry.

Caller’s sptr · len 3 · cap 4Function’s s (a copy)ptr · len 3 · cap 4Separate variables, same valuesBacking array · only one123Element writes shows[0] = 99Same array, so thecaller sees 99 tooappend doesn’t shows = append(s, 4)len 4 is only in the copycaller’s len is still 3
The header is copied, the backing array is shared
Element writes show up, append doesn't
func modify(s []int) {
s[0] = 99 // visible: same backing array
s = append(s, 4) // not visible: only the copied header's len changes
}
s := make([]int, 3, 4)
modify(s)
fmt.Println(s, len(s)) // [99 0 0] 3
// so a function that appends must return the slice
func appendTo(s []int, v int) []int { return append(s, v) }
s = appendTo(s, 4) // why the whole standard library looks like this

Having append return its result looked awkward to me, but with the header being a value type, there was no other option. It’s also why strings.Builder and bytes.Buffer are designed with pointer-receiver methods.

Here’s the real story behind “sometimes append changes the original and sometimes it doesn’t.” append does two completely different things depending on whether cap has room. And you can’t tell which from the code alone.

① When cap has roomwrites into the same array → original changesb := a[1:3] // len 2, cap 4before12345b = append(b, 99)after123995a == [1 2 3 99 5] · a’s 4 got overwritten② When cap runs outcopies to a new array → link is brokenc := a[1:3:3] // cap 2a12345c = append(c, 77)new2377a is untouched · c and a now go separate ways
With room in cap it writes into the original array; without it, it moves to a new one
Same append, different results
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // len 2, cap 4: cap extends to the end of the array
b = append(b, 99)
fmt.Println(a) // [1 2 3 99 5] original clobbered
a = []int{1, 2, 3, 4, 5}
c := a[1:3:3] // full slice expression: cap limited to 3-1 = 2
c = append(c, 77) // over cap, copied to a new array
fmt.Println(a) // [1 2 3 4 5] original safe

There are three ways to defend against it. Pick by situation.

Approach When What it does
Cut cap: a[l:h:h] Handing a slice to someone else Forces the next append to make a new array
Detach with copy Keeping it around long Copies just what you need into a new slice and fully breaks the reference
Preallocate: make([]T, 0, n) You know the size Eliminates reallocation and copying altogether

I also had “cap doubles” memorized, and that’s no longer true. Since Go 1.18, the threshold dropped from 1024 to 256, and above it growth tapers smoothly from 2x toward 1.25x, to avoid wasting double the memory on large slices. On top of that, the computed size is rounded up to the allocator’s size class, so the actual cap can be larger than you’d expect. That’s why code should never assume an exact cap.

The memory leak from reslicing

This is where the window nature causes trouble most quietly. A slice header references the entire backing array. So if you keep just 3 bytes cut from a 10 MB response, as far as the GC is concerned the 10 MB is still a live object.

Why the whole thing? As we saw, the array is one allocation unit. A 10 MB block on the heap has no notion of “keep the first 3 bytes and give back the rest.” A len of 3 doesn’t mean the data shrank to 3 bytes. It only means the window narrowed to the first 3 bytes of the block. As long as any pointer to that block is alive, the GC can’t touch any of it.

✕ Holding a sliver keeps the whole arrayheadlen 3 · cap 10M3Bthe rest: 10 MB, pinnedhead := body[:3] // 3 bytes of 10 MB✓ Break the reference with copyheadlen 3 · cap 33Bjust a new 3-byte arrayhead := make([]byte, 3); copy(head, body)
Keep a sliver and the whole array behind ptr stays alive

There’s one more in the same vein. When you trim elements off a slice of pointers, the pointers in the trimmed slots stay put, just past len. You can’t see them, but the GC keeps those objects alive.

✕Just trim it

func pop(s []*Item) []*Item {
// s[len(s)-1] still holds on to the Item
return s[:len(s)-1]
}

✓Nil it out, then trim

func pop(s []*Item) []*Item {
s[len(s)-1] = nil // break the reference first
return s[:len(s)-1]
}

Deleting from the middle works the same way. Since Go 1.22, slices.Delete zeroes the vacated tail for you, so use it instead of rolling your own.

🧪 Try it

Confirming the leak with heap stats

I measured whether it really doesn’t shrink, using runtime.ReadMemStats, comparing two ways to keep 3 bytes from a 10 MB array.

Reslicing versus copy
func heap() uint64 {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.HeapAlloc
}
big := make([]byte, 10<<20) // 10 MB
leak := big[:3] // reslice
big = nil
fmt.Println("reslice:", heap()) // the 10 MB stays
safe := make([]byte, 3) // detach with copy
copy(safe, leak)
leak = nil
fmt.Println("copy:", heap()) // the 10 MB is gone

At first I thought big = nil would be enough, but the heap didn’t budge. Of course: leak was holding the same block. It’s reclaimed only when every pointer to that block is gone. The 10 MB disappeared only after detaching with copy and then setting leak = nil.

What mattered more than the numbers was how hard this is to find with a profiler. go tool pprof -inuse_space attributes the allocation to io.ReadAll, while the real culprit is code far away that stashed a sliver in a cache. So I made it a rule: copy any slice you keep for long.

nil slice vs empty slice

The two behave the same in almost every way. They differ in only two places, and those two places happen to be API boundaries.

var s []int (nil) s := []int{} (empty)
len / cap 0 / 0 0 / 0
append, range Same behavior Same behavior
Heap allocation None None (only ptr is non-nil)
s == nil true false
JSON encoding null []

Go Code Review Comments recommends var s []T. It also points out the exception: building JSON responses. When there are no results, null goes out and .map() blows up on the frontend.

Maps: Swiss tables, and two deliberate unfriendlinesses

Go 1.24 replaced the map implementation wholesale. The “8-entry buckets plus overflow chains” that older material describes is history; maps are now based on Swiss tables. It’s worth seeing the structure once to understand the traps.

Split hash(key) in twoh1 · upper 57 bits · picks the grouph2low 7 bitsGroup = 8 slots + an 8-byte control wordctrl238950—————slots563221Looking up key 32 (h2 = 89)Compare all 8 control bytes at once to find candidatesThe layer Go adds · split tablesdirectoryby upper hash bitstable 0≤ 1024 slotstable 1≤ 1024 slotstable 2≤ 1024 slotsTables grow one at a time → no latency spikes
Since Go 1.24, maps are groups of 8 slots under split tables

More important than performance, this structure explains why maps are so unfriendly. Both of their most-complained-about traits are deliberate.

① Iteration order changes every time

The runtime picks the starting point of iteration at random on purpose. A hash table has no defined order anyway, but left alone, you’d get situations where the order happens to be the same every time, and developers would start relying on it. Then one day, with slightly different data, it breaks in production. Randomization forces that bug to surface during development. If you need order, extract the keys and sort them.

② A detected concurrent write kills the process

And it’s a fatal error, not a panic. I missed this when I learned it from flashcards, and the difference is big. recover can’t catch it, deferred functions don’t run, and the process ends right there.

recover doesn't catch it
m := map[int]int{}
for i := 0; i < 100; i++ {
go func(i int) {
defer func() {
if r := recover(); r != nil { // this recover never runs
fmt.Println("recovered:", r)
}
}()
m[i] = i
}(i)
}
Output
fatal error: concurrent map writes
# when a read and a write overlap
fatal error: concurrent map read and map write

It goes this far because if the structure breaks while a table is growing, the result is memory corruption you can’t trace. Dying on the spot with a stack trace beats quietly returning wrong values or stomping on someone else’s memory.

That detection is best effort, not a guarantee, though. If the timing is off, it can slip by silently. So you still need -race.

So what do you use in practice?

Approach When Cost
Wrap with sync.RWMutex The default. Start here when in doubt Lock contention when writes are frequent
Sharding When contention actually shows up in a profile Iterating everything and counting size get awkward
sync.Map Overwhelmingly reads with a stable key set (caches) No type safety. Often slower for typical mixed reads and writes
A map wrapped in an RWMutex
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(k string) (int, bool) {
s.mu.RLock() // many readers at once
defer s.mu.RUnlock()
v, ok := s.m[k]
return v, ok
}
func (s *SafeMap) Set(k string, v int) {
s.mu.Lock() // one writer at a time
defer s.mu.Unlock()
s.m[k] = v
}

Finally, two lesser-known traps. You can’t take the address of a map element (&m[k] is a compile error, covered in the previous post), and delete doesn’t shrink memory. Slots are just marked deleted; the table stays the same size. If a map balloons once and then empties out, the only fix is to build a new map and move the live entries over.

Strings: not an array of characters

A Go string is a read-only bundle of UTF-8 bytes. Its header is just two words, a data pointer and a length, and it’s immutable. Approach it as an array of characters and it breaks your intuition in three places.

The actual memory of s := “한글Go”index0ED19529C3EA4B858064776F‘한’ 3 bytes‘글’ 3 bytes‘G’‘o’Not what you expectlen(s) == 8bytes, not characterss[0] == 0xEDa byte, not ‘한’range walks runes0 ‘한’ 3 ‘글’6 ‘G’ 7 ‘o’i is still a byte offsetA rune isn’t a visible character eitherFamily emoji (3 people + 2 ZWJs) = 5 runes, 18 bytesDecomposed (NFD) ‘한’ = 3 jamo = 3 runes, 9 bytesCount characters as grapheme clusters
Bytes, runes, and the characters people see (graphemes) are different units
len, indexing, and range
s := "한글Go"
fmt.Println(len(s)) // 8: number of bytes
fmt.Println(utf8.RuneCountInString(s)) // 4: number of code points
fmt.Println(s[0]) // 237 (0xED): a single byte
for i, r := range s { // only range walks by rune
fmt.Println(i, string(r)) // 0 한 / 3 글 / 6 G / 7 o
} // i is a byte offset, so 1 and 2 are skipped
// with invalid UTF-8, range yields U+FFFD (�) one byte at a time; it never loops forever

The cost of immutability: += is O(N²)

Because strings are immutable, changing one means creating a new one. Concatenating with += in a loop copies everything built so far into a new buffer on every iteration. After N iterations, total copying is proportional to N².

✕Concatenating with +=

// copies everything so far, every time
var s string
for _, w := range words {
s += w
}

✓strings.Builder

// appends into a single buffer
var b strings.Builder
b.Grow(totalLen) // no reallocation if you know the size
for _, w := range words {
b.WriteString(w)
}
s := b.String() // handed over without a copy

For simple joining, strings.Join(words, "") reads better. It allocates once internally.

Converting between string and []byte copies, with exceptions

Strings are immutable and []byte is mutable, so converting between them copies by default. Otherwise you could change a string by modifying the []byte. Repeat this conversion on a hot path and invisible allocations pile up.

In a few places, though, the compiler can prove the result of the conversion doesn’t outlive the expression, and it skips the copy.

Where the copy is skipped, and where it isn't
b := []byte("key")
v := m[string(b)] // map lookup: copy skipped
if string(b) == "key" {} // comparison: copy skipped
s := string(b) // stored in a variable: copied (it outlives the expression)
// if you truly must avoid the copy (Go 1.20+), only when you're sure
s = unsafe.String(unsafe.SliceData(b), len(b))
// modify b afterward and s changes too = the immutability contract is broken

Which spots get the optimization widens a little with each compiler version. On a hot path, don’t guess. Check allocs/op with go test -bench . -benchmem.

🧪 Try it

Character count has three answers

To see why there’s no single way to measure a string’s length, I printed all three levels at once.

Bytes and runes
for _, s := range []string{"한글Go", "👨‍👩‍👧", "é"} {
fmt.Printf("%-10q len=%2d runes=%d\n", s, len(s), utf8.RuneCountInString(s))
}
Output
"한글Go" len= 8 runes=4
"👨‍👩‍👧" len=18 runes=5
"é" len= 3 runes=2

The number of characters a person sees is 4, 1, and 1. The last é is the decomposed form, an e followed by a combining accent (U+0301). All three lines give different answers. This is why DB column length limits, character counters on inputs, and string truncation keep breaking in practice. Cut by len and you split characters; cut by rune and you still split emoji. If you need units people perceive as characters, you need a library that handles grapheme clusters.

Wrap-up

  • A slice is a window. Only the three-word header is passed; the data is shared. The asymmetry where element writes show but append doesn’t, append behaving differently depending on spare cap, and slivers pinning entire arrays all come from this.
  • a[l:h:h] when handing a slice off, copy when keeping it. Those two rules alone eliminate most slice accidents.
  • A map’s unfriendliness is all by design. Randomized iteration breaks order-dependent code early, and the concurrent-write fatal error prevents silent memory corruption. The latter can’t be caught with recover and isn’t guaranteed to fire, so you need -race.
  • A string is immutable UTF-8 bytes. Bytes, runes, and graphemes are all different; because strings are immutable, build them with strings.Builder and assume conversions copy.

Lined up side by side, the three data structures had one thing in common: Go doesn’t hide the reality for convenience. Slices don’t hide that they’re windows, maps don’t pretend to guarantee element addresses, and strings don’t pretend to be character arrays. It felt unfriendly at first, but had they hidden it, I think I’d have paid the price at a much worse moment.

Edit on GitHub
esc

Recent posts

↑↓ move↵ openOpen search page ›