Slices & Maps
📋 Jump to TakeawaysSlices and maps are the data structures you'll reach for in every Go program. Slices are Go's dynamic arrays: flexible, efficient, and everywhere. Maps are your key-value store. Together they handle 90% of the data you'll ever work with.
Arrays
Arrays have a fixed size set at compile time. You'll rarely use them directly. Slices are almost always the better choice.
var a [3]int = [3]int{1, 2, 3}
b := [3]string{"go", "is", "fun"}
fmt.Println(a[0]) // 1
fmt.Println(len(b)) // 3The size is part of the type. [3]int and [4]int are different types.
Slices
A slice is a dynamic, flexible view over an array. This is what you'll use 99% of the time.
s := []int{1, 2, 3}
fmt.Println(s[0]) // 1
fmt.Println(len(s)) // 3What a Slice Really Is
A slice feels like a simple list, but it's actually a tiny struct with three fields:
// Roughly what Go uses internally
type slice struct {
array unsafe.Pointer // pointer to the underlying array
len int // how many elements you're using
cap int // how many fit before a new array is needed
}That's it. When you write []int{1, 2, 3}, Go allocates an array somewhere in memory and hands you back this little header pointing to it. The slice never holds the data itself.
This is why slices feel like references. Pass one to a function and Go copies the header, not the array. Both copies point to the same data.
func double(s []int) {
for i := range s {
s[i] *= 2
}
}
nums := []int{1, 2, 3}
double(nums)
fmt.Println(nums) // [2 4 6], modified through the copyWhen append runs out of room, Go quietly allocates a bigger array, copies everything over, and gives you a new header pointing to it. The old array becomes garbage. You never deal with any of this. Go roughly doubles the capacity each time.
s := make([]int, 0, 2) // cap=2
s = append(s, 1, 2) // fits in existing array
s = append(s, 3) // cap exceeded → new array, cap growsAnd that's exactly why s = append(s, ...) needs the reassignment. If the array grew, your old header is pointing at stale memory.
When You Need a Pointer to a Slice
Mutating elements works fine across functions. Both headers point to the same array. But append is different. If a function appends, its local header gets the new len (and maybe a new array pointer), but the caller's header knows nothing about it.
func addItem(s []int, val int) {
s = append(s, val) // caller never sees this
}
nums := []int{1, 2}
addItem(nums, 3)
fmt.Println(nums) // [1 2], unchangedPass a pointer to the slice so the function updates the caller's header directly:
func addItem(s *[]int, val int) {
*s = append(*s, val)
}
nums := []int{1, 2}
addItem(&nums, 3)
fmt.Println(nums) // [1 2 3]You can also just return the new slice (that's what append itself does). Reach for a pointer when you can't change the return type: callbacks, interface methods, or functions that already return an error.
make
Use make to create a slice with a specific length and optional capacity.
s := make([]int, 3) // len=3, cap=3, [0 0 0]
s2 := make([]int, 0, 5) // len=0, cap=5, []make vs new
new allocates memory and returns a pointer to a zero value. make initializes slices, maps, and channels with their internal structure ready to use. You can't use new for slices or maps because they need that internal setup to work.
append
append adds elements and returns a new slice. You must reassign the result.
s := []int{1, 2}
s = append(s, 3)
s = append(s, 4, 5)
fmt.Println(s) // [1 2 3 4 5]Forgetting to reassign is a common bug. append may allocate a new underlying array.
len vs cap
len is how many elements the slice holds. cap is how many it can hold before needing a new allocation.
s := make([]int, 2, 5)
fmt.Println(len(s)) // 2
fmt.Println(cap(s)) // 5Slicing
Slicing creates a new slice that shares the same underlying array.
s := []int{10, 20, 30, 40, 50}
fmt.Println(s[1:3]) // [20 30]
fmt.Println(s[:2]) // [10 20]
fmt.Println(s[2:]) // [30 40 50]Changes to a sub-slice affect the original.
sub := s[1:3]
sub[0] = 99
fmt.Println(s) // [10 99 30 40 50]copy
copy creates an independent copy. Changes to one don't affect the other.
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
dst[0] = 99
fmt.Println(src) // [1 2 3]
fmt.Println(dst) // [99 2 3]Since Go 1.21, slices.Clone does the same thing in one call:
import "slices"
src := []int{1, 2, 3}
dst := slices.Clone(src)slices.Clone is just append([]int(nil), src...) internally. Same result, less boilerplate.
nil Slice vs Empty Slice
A nil slice has no underlying array. An empty slice points to an array of length zero. Both have len 0 and work with append.
var nilSlice []int // nil
emptySlice := []int{} // not nil, but empty
fmt.Println(nilSlice == nil) // true
fmt.Println(emptySlice == nil) // false
fmt.Println(len(nilSlice)) // 0
fmt.Println(len(emptySlice)) // 0Appending to a nil slice doesn't panic. Go allocates the array for you:
var s []int
s = append(s, 1, 2, 3)
fmt.Println(s) // [1 2 3]This means var s []int is a perfectly valid way to start building a slice. No need to make it first.
Maps
A map is an unordered collection of key-value pairs.
m := map[string]int{
"alice": 90,
"bob": 85,
}
fmt.Println(m["alice"]) // 90Like slices, a map is already a pointer internally. When you pass a map to a function, both the caller and the function see the same data. You almost never need *map[K]V.
func addScore(m map[string]int) {
m["charlie"] = 95 // modifies the caller's map
}
scores := map[string]int{"alice": 90}
addScore(scores)
fmt.Println(scores) // map[alice:90 charlie:95]The only rare case for *map is when a function needs to replace the entire map, not just modify its contents. But in practice, people just return the new map from the function instead.
make for Maps
Use make to create an empty map.
m := make(map[string]int)
m["score"] = 100
fmt.Println(m) // map[score:100]You can pass a size hint to avoid rehashing when you know roughly how many keys to expect. It's not a hard limit, the map still grows if needed.
m := make(map[string]int, 100) // preallocate space for ~100 keysAccess, Insert, Delete
m := map[string]int{"a": 1, "b": 2}
// Access
fmt.Println(m["a"]) // 1
// Insert or update
m["c"] = 3
// Delete
delete(m, "b")
fmt.Println(m) // map[a:1 c:3]Accessing a missing key returns the zero value. No error, no panic.
fmt.Println(m["missing"]) // 0To remove all keys at once, use clear (Go 1.21+). The map stays usable, just empty.
m := map[string]int{"a": 1, "b": 2}
clear(m)
fmt.Println(m) // map[]
fmt.Println(len(m)) // 0
m["c"] = 3 // still worksComma-ok Pattern
Use the two-value form to check if a key actually exists.
m := map[string]int{"a": 1}
val, ok := m["a"]
fmt.Println(val, ok) // 1 true
val, ok = m["z"]
fmt.Println(val, ok) // 0 false
if v, ok := m["a"]; ok {
fmt.Println("found:", v) // found: 1
}Iteration Order Is Random
Go randomizes map iteration order on purpose. Never rely on it.
m := map[string]int{"a": 1, "b": 2, "c": 3}
for k, v := range m {
fmt.Println(k, v) // order varies each run
}Key Takeaways
- Arrays are fixed-size and rarely used directly. Prefer slices
- A slice is a header (pointer, len, cap) that points to an underlying array. Go manages the array
- Passing a slice copies the header, not the data. Both point to the same array
appendmay allocate a new array, so always reassign:s = append(s, val)- If a function appends to a caller's slice, pass
*[]T. The caller's header won't update otherwise lenis current size,capis allocated capacity- Sub-slices share memory with the original. Use
copyfor independence - A nil slice and an empty slice both have
len0 but differ in nil checks - Maps are unordered key-value stores created with literals or
make - Use the comma-ok pattern (
val, ok := m[key]) to check key existence - Map iteration order is intentionally randomized