An array is a fixed-size value whose length is part of its type, so assigning or passing it copies every element; a slice is a small header (pointer to an array, len and cap) that views part of an array it shares with others.
len is how many elements you can index now; cap is how many fit before the end of the underlying array, which is how far append can go without allocating.
[4]int and [5]int are different types, so a function taking [4]int accepts nothing else. That is why arrays are rare in signatures.
Copying a slice copies only the header (24 bytes on 64-bit), so both copies see each other's writes to s[i].
Arrays compare with == and can be map keys; slices compare only with nil, and their equality check is slices.Equal.
Use a slice almost always, and an array for a fixed-width value like the [32]byte from sha256.Sum256.
GoJunior
Slices, Maps & Strings
Slice headers, append aliasing, map internals, runes and strings.Builder
Questions closed
0 / 24
- 0Review
- 9Asked often
Questions
The zero value of a slice is **nil: a header with a nil pointer, len 0 and cap 0**. An empty slice such as []int{} also has len 0 but a non-nil pointer. Almost all code treats the two the same.
len, cap, range, append and s[:0] all work on a nil slice. append allocates the array itself, so there is no need to make a slice before appending to it.
Check emptiness with len(s) == 0, not s == nil: the second misses empty slices that are not nil.
The difference shows in encoding/json: nil is encoded as null, an empty slice as []. A client that calls .length on that field crashes on null, so an API field that promises a list should get make([]T, 0, n).
reflect.DeepEqual also tells them apart, while slices.Equal treats them as equal.
make([]T, n) creates a slice that already holds n zero values (len n); make([]T, 0, n) is empty with room for n (len 0, cap n). append always writes after len, so appending to the first one leaves n zero values in front of your data.
Two correct patterns: make([]T, n) and assign out[i] = v, or make([]T, 0, n) and out = append(out, v). Mixing them is the bug.
Reserving capacity up front saves the repeated grow-and-copy that append does on a slice with no room left.
The hint can be approximate: too large wastes a little memory, too small just lets normal growth take over.
b := a copies only the header, so both still share one array; a real copy needs new memory: **slices.Clone(a)** (Go 1.21+), or make with the right length plus copy. copy counts len, not cap, so it copies nothing into make([]T, 0, n).
copy(dst, src) copies min(len(dst), len(src)) elements and returns that number. It looks at length, not capacity, so a destination made with make([]T, 0, n) receives nothing.
append([]T(nil), a...) is the older idiom. It turns an empty a into nil, which matters for JSON; slices.Clone keeps nil as nil and empty as empty.
Every one of these copies is shallow: if the elements are pointers, maps or slices, both copies still point at the same data.
To join slices into new memory, use slices.Concat(a, b) (Go 1.22+).
It prints [{a 1} {b 2}]: the range variable it is a copy of each element, so it.Count++ changes the copy and the slice stays as it was.
Index the slice instead: for i := range items { items[i].Count++ } writes to the element itself.
Or take a pointer to the element, p := &items[i], when you change several fields.
With []*Item the copy is a pointer, so it.Count++ does reach the shared struct.
Go 1.22 made it a new variable on each iteration, which fixed closures capturing it. It is still a copy.
A Go string is an immutable sequence of bytes, usually UTF-8, so len counts bytes and s[0] is one byte. Дмитрий is 7 letters, each Cyrillic letter takes 2 bytes, so 14.
A non-ASCII character takes 2 to 4 bytes. name[0] is 208, the first half of Д, and slicing in the middle of a letter gives invalid UTF-8.
Count characters with utf8.RuneCountInString(s). A rune (alias for int32) is one Unicode code point; []rune(s) lets you index by character, at the cost of a copy.
for i, r := range s decodes UTF-8 for you: r is a rune and i is the byte offset, so it jumps by 1–4.
s[0] = 'x' does not compile. To change a string, convert to []byte or []rune, edit, and convert back; both conversions copy.
Reading a map never fails: a missing key returns the zero value, so presence is asked separately with the comma-ok form v, ok := m[k], where ok is true only if the key is really there; writing to a nil map panics, reading from it is fine.
_, ok := m[k] is the membership test; that is also how you query a set built as map[string]struct{}.
The zero value is what makes counts[word]++ work on a fresh map, and m[k] = append(m[k], v) work for a key with no slice yet.
A nil map (var m map[string]int) behaves like an empty one for reading: m[k], len, range and delete all work. Writing panics with assignment to entry in nil map.
Create a map with make(map[K]V) or a literal before the first write, typically in the constructor of the struct that holds it.
The spec does not define map order, and **the runtime deliberately starts each range at a random position**, so no code can come to rely on one. For stable output, sort the keys and range over them.
It keeps the implementation free to change (it did change, to Swiss tables in Go 1.24), and it makes order-dependent code fail early rather than after an upgrade.
A small map can look stable for a while, which is why this usually shows up as a flaky test, not a failure on day one.
slices.Sorted(maps.Keys(m)) (Go 1.23+) gives the sorted keys in one line.
fmt.Println(m), encoding/json and text/template already sort map keys; only your own range loops are unordered.
Use **slices.SortFunc(s, cmp)** with a function that returns a negative number, zero or a positive number, usually built from cmp.Compare; cmp.Or adds a second key that is used only when the first one is equal.
slices.SortFunc (Go 1.21+) is generic and usually faster than the older sort.Slice, which swaps elements through reflection.
Neither is stable: equal elements may change places. Use slices.SortStableFunc when the existing order must survive, for example a list already sorted by date.
For descending order, swap the arguments: cmp.Compare(b.Age, a.Age).
Comparing strings compares bytes, not language rules. Names shown to users in Russian or German need golang.org/x/text/collate.
Go passes everything by value, and a slice's value is its header: the function gets its own copy of pointer, len and cap, pointing at the caller's array. It prints [100 200 0] 3 [100 200 0 4].
s[0] = 100 writes through the shared pointer, so the caller sees it.
append finds spare capacity (cap 10), so it puts 4 into the same array, but only the function's copy of len becomes 4. The caller's len is still 3; the 4 is there, past its length, and a[:4] shows it.
No reallocation happened, so s[1] = 200 also lands in the shared array. With make([]int, 3) the append would allocate a new array, s[1] = 200 would go there, and the caller's a would be [100 0 0] (and a[:4] would panic, since cap is 3).
Whether the caller sees a change therefore depends on capacity, which is why these bugs come and go.
The fix follows the shape of append itself: return the slice and assign it, a = add(a). A *[]int parameter also works, but it is rare and harder to read.
If the new elements fit in cap, **append writes into the existing array and allocates nothing**; otherwise it allocates a bigger array, copies the elements over and returns a header pointing at it. Small slices double; from 256 elements the growth eases off towards 1.25×.
The rule in runtime.growslice (Go 1.18+): if the needed length is more than twice the old cap, take exactly that; below 256 elements, double; above, add (cap + 768) / 4 per step, a smooth slide from 2× to 1.25×.
The result is rounded up to an allocator size class, so caps do not follow the formula exactly: an []int goes from 512 to 848, not 832.
Before Go 1.18 the switch was a hard step at 1024 elements: 2× below, 1.25× above. Older articles still quote it.
After a reallocation the old array is left as it was, and other slices over it stop seeing your writes, which is the root of most aliasing surprises.
Appending one by one is amortised O(1), but each reallocation copies everything so far; preallocate with make([]T, 0, n) when you know n.
It prints [0 0 2] [0 0 2] and then 0 7. **Both appends wrote into the one free slot of base's array**, so y overwrote what x had put there; z was appended to a full slice, so it got a new array.
x and y are separate headers over the same memory: &x[0] == &y[0]. Any number of slices can share an array, and none of them owns it.
append(x, 3) needs a fourth slot while x has cap 3, so the runtime copies into a new array. From then on, writes to z do not touch x.
Whether two slices share memory depends on capacity at that moment, so the same code can behave differently for different input sizes.
The fixes: hand each caller base[:len(base):len(base)], so its first append has to copy, or slices.Clone(base) when the receiver keeps or changes the data.
It slices like s[low:high] but also **sets the capacity to max - low**, so the result cannot reach the elements past max. With max == high the first append has to allocate a fresh array instead of overwriting the caller's data.
Use it when you hand part of your buffer to code you do not control: return buf[:n:n] guarantees that the caller's append never writes into buf[n:].
The bounds must satisfy low <= high <= max <= cap(s), otherwise it panics with slice bounds out of range.
It only limits growth. Writing s[i] for i < max - low still changes the shared array; if the receiver keeps or edits the data, clone it.
It is not allowed on strings, which cannot be appended to in place anyway.
Strings are immutable, so **every s += part allocates a new string and copies everything built so far** into it: n appends copy O(n²) bytes. strings.Builder appends into one growing buffer and returns the result without a final copy.
A 100 KB report assembled from 10 000 short lines this way allocates around 500 MB of throwaway strings.
b.Grow(n) when you know the size makes it a single allocation.
strings.Join(parts, sep) when the parts are already in a slice: it sums the lengths and allocates once.
bytes.Buffer when you need bytes or an io.Reader. Both are io.Writers, so fmt.Fprintf(&b, ...) writes straight into them.
A strings.Builder must not be copied after first use: pass *strings.Builder, or it panics with strings: illegal use of non-zero Builder copied by value.
Two or three pieces in one expression, a + ":" + b, are fine: the compiler builds them with one allocation.
Deleting is safe and well defined: an entry removed before the loop reaches it is simply not produced. Adding is legal but unpredictable: a key inserted during the loop may or may not be visited in that same loop.
So for k, v := range m { if v.Expired() { delete(m, k) } } is correct Go, and you do not need to collect the keys first. maps.DeleteFunc(m, pred) (Go 1.21+) does the same in one line.
An insert can make the table grow and move entries under the iterator. The spec leaves the outcome open instead of paying to make it deterministic, so code that depends on it gives different results from run to run.
When the loop has to add keys, collect them in a slice and insert after the loop.
All of this is about one goroutine. Changing a map from another goroutine during a range is a data race and can crash the process.
Map elements are not addressable (the map may move entries when it grows), so you cannot assign to a field of a map value or take its address (cannot assign to struct field m["a"].Count in map). Copy the struct out, change it and store it back, or keep pointers in the map.
Copy, modify, store: it := m[k]; it.Count++; m[k] = it.
map[string]*Item lets m[k].Count++ work, but a missing key gives a nil pointer, so check ok first, and shared pointers need care with concurrency.
A slice element is addressable, so items[i].Count++ works; this error never appears with slices.
The same rule rejects &m[k] and pointer-method calls like m[k].Inc().
A map is a hash table: the key's hash, with a random per-map seed, decides where to look, so get, put and delete are O(1) on average. Since Go 1.24 the runtime uses Swiss tables (groups of 8 slots with a control byte per slot) instead of buckets with overflow chains.
Up to Go 1.23: buckets
The map header (hmap) pointed at 2^B buckets. Each bucket held 8 keys, 8 values and 8 tophash bytes, the top bits of each hash, used as a quick pre-check.
The low bits of the hash picked the bucket; a full bucket chained an overflow bucket.
At an average of 6.5 entries per bucket the map doubled and evacuated the old buckets gradually, a few on each write, so no single insert paid for the whole move.
Since Go 1.24: Swiss tables
The hash splits into H1 (57 bits), which picks the group to probe, and H2 (7 bits), kept in the control byte. A lookup compares H2 against all 8 control bytes of a group at once and checks full keys only for matches.
A table holds up to 1024 slots and doubles when it is 7/8 full; past that size it splits in two, and a directory picks the table by the top bits of the hash. Each growth step is bounded by one table instead of the whole map.
A map with up to 8 entries is a single group with no table at all.
What did not change
Random iteration order, values you cannot take the address of, and the concurrent map writes crash.
A map never shrinks: delete and clear empty slots but keep the table. To give memory back, replace the map with a new one.
No. Concurrent reads are fine, but a write alongside any other access is a data race, and the runtime may stop the process with **fatal error: concurrent map writes**, which is a fatal error, not a panic, so recover() cannot catch it.
The check is a cheap flag set during writes, so it fires only when two accesses actually overlap. The race can pass a week of staging and crash in production; go test -race reports it on the first unsynchronised access.
The process has to die: a write interleaved with table growth can corrupt the map, and serving from a corrupted table is worse than crashing.
The usual fix is a sync.RWMutex (or sync.Mutex) next to the map, locked on every access, reads included.
sync.Map fits two cases: keys written once and read many times, or goroutines working on disjoint keys. Otherwise it is usually slower than a map with a mutex. Under heavy contention, shard: N maps, each with its own lock.
A map fully built before any goroutine starts and never written again needs no lock at all.
Since Go 1.21 they cover the loops everyone used to write by hand (slices.Contains, Index, Sort, BinarySearch, Compact, maps.Clone). Sort and Reverse work in place; Delete, Insert, Compact and Replace return a new header you must assign.
slices.Sort, SortFunc and Reverse work in place and return nothing.
Delete, DeleteFunc, Compact, Insert and Replace return a new header, like append, so always assign: s = slices.Delete(s, i, i+1). They reuse the backing array when it has room, so other slices over that array see the shifted elements.
Since Go 1.22 the delete-style functions zero the slots past the new length, so dropped pointers can be garbage-collected.
slices.BinarySearch needs sorted input; on unsorted input it returns a wrong answer without any error.
maps.Keys returns an iterator (iter.Seq), not a slice. Maps compare with == only against nil; use maps.Equal.
The loop advances i after removing an element, but append(sessions[:i], sessions[i+1:]...) has just shifted the next session into index i, so it is never checked: of two adjacent expired sessions, the second one survives.
Why it happens: removing in place copies the tail one slot to the left in the same backing array. After i++ the loop looks at what used to be i+2. With a b c d e where all but c are expired, the result is b c e.
Why it does not panic: i < len(sessions) is re-evaluated each pass. Written as for i := range sessions, the length is fixed at the start, and the same bug ends in index out of range once anything is removed.
The fix: advance i only when the element stays. Simpler still: slices.DeleteFunc(sessions, func(s Session) bool { return s.ExpiresAt.Before(now) }) does one O(n) pass instead of an O(n) shift per removal, and since Go 1.22 zeroes the freed tail.
The caller: the function reuses the caller's backing array, so the slice you passed in is left with shifted, duplicated elements; always use the returned slice.
How to catch it: a table test with two expired items in a row and one at the end; a single expired item in the middle passes.
No. Reslicing only changes the header; the backing array stays allocated while any slice points into it, so keeping body[:64] of a 4 MB request body keeps all 4 MB alive.
The garbage collector frees whole objects. A pointer anywhere inside the array keeps the whole array reachable; it cannot release 4 MB minus 64 bytes.
s = s[:0], s = s[2:] and s = s[:5] never shrink the allocation. Strings behave the same: big[:10] of a 1 MB string pins the megabyte.
Copy out what you keep with bytes.Clone, strings.Clone or slices.Clone; the big array becomes garbage at the next cycle.
A slice of pointers also pins the objects past its length: clear(s[n:]) before shortening, covered in the next question.
A heap profile charges this memory to the code that allocated the array, not to the code that kept the subslice; a long-lived slice whose cap is far above its len is the clue.
The append shifts the tail left inside the same array and returns a header one shorter, but the old last slot still holds its value. In a slice of pointers that stale copy keeps the object reachable for as long as the array lives.
Nothing is allocated or freed. The removed element is gone from s, but a duplicate of the last element sits past len, invisible to you and visible to the garbage collector.
Since Go 1.22 slices.Delete(s, i, i+1) does the same shift and then zeroes the vacated slots, so they stop pinning objects. Before that you wrote s[len(s)-1] = nil yourself.
It is still in place: any other slice over that array sees the shifted elements.
One deletion is O(n), so deleting matches one by one in a loop is O(n²). slices.DeleteFunc removes all of them in a single pass.
When order does not matter, move the last element into the gap and shorten the slice: O(1).
Reading a slice nobody writes is safe; a write while another goroutine reads or writes the same element is a data race, and append races too, because it updates the header and may write into a shared array. Give each goroutine its own part, or hand over ownership.
Writes to different indexes do not race: workers filling out[i] for their own i are fine. Wait on a sync.WaitGroup before anyone reads the result.
Appending to one shared slice variable from several goroutines loses elements or corrupts the header. Give each worker its own slice and merge afterwards, or guard it with a mutex.
When workers get sub-slices of one buffer, cut them with buf[lo:hi:hi], so a worker's append cannot spill into its neighbour's range.
A buffer sent over a channel or put back into a sync.Pool belongs to the receiver: the sender must not touch it again, and nobody may keep a sub-slice of it, or they read bytes the next user has overwritten.
go test -race finds the first unsynchronised access.
Add stores a pointer into the slice's current backing array, but when append runs out of capacity it copies users into a new array; older pointers still aim at the old copy, so Rename writes into memory that List never reads.
Why it happens: &r.users[i] is the address of an element in one specific array. Each reallocation moves the elements, and every pointer taken before it goes stale. Only users added after the last growth rename correctly, so the bug looks random: in a three-user run, renaming users 1 and 2 is lost and only user 3 works.
No crash, no race: the old array is still valid memory, kept alive by those very pointers, so there is no panic and -race stays silent (it is a single goroutine).
The fix: keep the index instead of the address: an append never changes the index of existing elements. If you also delete from the middle, indexes shift too; then store []*User so the slice and the map share one heap object per user.
Preallocating is not a fix: make([]User, 0, 1000) only hides the bug until user 1001.
How to catch it: a test that adds more items than the initial capacity and then mutates the first one; in review, treat any &s[i] kept past an append as a bug.