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.
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.
// array: the variable is the data. There's no pointer involvedvar arr [4]int = [4]int{1, 2, 3, 4} // arr is the memory for 4 ints itself
// one slice literal actually creates two thingss := []int{1, 2, 3, 4}// ① an underlying array somewhere holding {1,2,3,4}// ② in s, a {ptr, len, cap} header pointing at that arrayIt’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.
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 slicefunc appendTo(s []int, v int) []int { return append(s, v) }s = appendTo(s, 4) // why the whole standard library looks like thisHaving 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.
The two paths of append: clobbering the original or breaking the link
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.
a := []int{1, 2, 3, 4, 5}
b := a[1:3] // len 2, cap 4: cap extends to the end of the arrayb = 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 = 2c = append(c, 77) // over cap, copied to a new arrayfmt.Println(a) // [1 2 3 4 5] original safeThere 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.
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.
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] // reslicebig = nilfmt.Println("reslice:", heap()) // the 10 MB stays
safe := make([]byte, 3) // detach with copycopy(safe, leak)leak = nilfmt.Println("copy:", heap()) // the 10 MB is goneAt 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.
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.
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)}fatal error: concurrent map writes
# when a read and a write overlapfatal error: concurrent map read and map writeIt 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 |
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.
s := "한글Go"
fmt.Println(len(s)) // 8: number of bytesfmt.Println(utf8.RuneCountInString(s)) // 4: number of code pointsfmt.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 foreverThe 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 timevar s stringfor _, w := range words { s += w}✓strings.Builder
// appends into a single buffervar b strings.Builderb.Grow(totalLen) // no reallocation if you know the sizefor _, w := range words { b.WriteString(w)}s := b.String() // handed over without a copyFor 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.
b := []byte("key")
v := m[string(b)] // map lookup: copy skippedif 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 sures = unsafe.String(unsafe.SliceData(b), len(b))// modify b afterward and s changes too = the immutability contract is brokenWhich 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.
for _, s := range []string{"한글Go", "👨👩👧", "é"} { fmt.Printf("%-10q len=%2d runes=%d\n", s, len(s), utf8.RuneCountInString(s))}"한글Go" len= 8 runes=4"👨👩👧" len=18 runes=5"é" len= 3 runes=2The 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,copywhen 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
recoverand 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.Builderand 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.