1) Arrays: fixed size, value semantics, and where you’ll see them
An array in Go has a fixed length that is part of its type. That means [3]int and [4]int are different types. Arrays are useful when the size is known at compile time and should never change.
Fixed size is part of the type
var a [3]int // exactly 3 ints, initially all 0s
b := [3]int{1, 2, 3}
// c := [4]int{1, 2, 3, 4} // different type than [3]intValue semantics (arrays are copied on assignment)
Arrays behave like values: assigning or passing an array copies all elements. This is a key mental model difference from slices.
a := [3]int{1, 2, 3}
b := a // copies all 3 elements
b[0] = 99
// a is still [1 2 3], b is [99 2 3]When arrays appear in APIs
In everyday Go code, you’ll more often use slices, but arrays show up in APIs when the size is meaningful and fixed, for example:
- Cryptographic hashes:
[32]byte(SHA-256),[16]byte(UUID-like fixed sizes) - Low-level protocols or binary formats with fixed-length fields
- Performance-sensitive code where fixed size enables optimizations
// A function that requires exactly 32 bytes (e.g., a SHA-256 digest)
func useDigest(d [32]byte) {
// ...
}If you want to avoid copying large arrays, you’ll often pass a pointer to the array instead.
func mutate(a *[3]int) {
(*a)[0] = 42
}2) Slices: length vs capacity, underlying array, slicing, and append reallocation
A slice is a lightweight descriptor that refers to a contiguous segment of an underlying array. The slice itself is not the data; it points to the data.
Continue in our app.
You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.
Or continue reading below...Download the app
Length vs capacity
len(s) is how many elements are currently in the slice. cap(s) is how many elements you can grow to (via append) before needing a new underlying array.
s := []int{10, 20, 30, 40}
// len(s) == 4
// cap(s) >= 4 (often 4 for a literal, but not guaranteed in general)The underlying array concept (sharing)
Multiple slices can refer to the same underlying array. Mutating through one slice can affect the other if they overlap.
base := []int{1, 2, 3, 4, 5}
a := base[1:4] // elements: 2,3,4
b := base[2:5] // elements: 3,4,5
a[1] = 99
// base is now [1 2 99 4 5]
// b sees the change because it overlaps the same underlying arraySlicing expressions
The common form is s[low:high] where low is inclusive and high is exclusive. You can omit bounds: s[:high], s[low:], s[:].
s := []string{"a", "b", "c", "d"}
left := s[:2] // "a", "b"
mid := s[1:3] // "b", "c"
all := s[:] // full slice (still shares underlying array)There is also a full slice expression s[low:high:max] that lets you limit capacity to control future append behavior.
base := []int{1, 2, 3, 4, 5}
sub := base[1:3:3] // len=2 (2,3), cap=2 (capacity limited)
sub = append(sub, 99)
// Because cap was limited, append must allocate a new array.
// base is unchanged by this append.How append can reallocate
append adds elements to a slice. If the slice has enough capacity, it reuses the same underlying array. If not, it allocates a new array, copies elements, and returns a new slice header pointing to the new array. Always assign the result of append.
s := make([]int, 0, 2)
s = append(s, 1, 2) // fits in cap=2
s2 := append(s, 3) // exceeds cap, likely reallocates
// s2 may now point to a different underlying array than s.A common bug is appending to a slice inside a function but forgetting to return it (or forgetting to assign the returned slice). The caller won’t see growth if reallocation happened.
3) Creating slices: literals, make, and nil vs empty (comparisons and JSON)
Slice literals
Use a literal when you know the initial elements.
nums := []int{1, 2, 3}
words := []string{"go", "is", "fast"}make for length and capacity
make([]T, len, cap) allocates an underlying array and returns a slice. If you omit cap, it equals len.
a := make([]int, 5) // len=5, cap=5, elements are 0
b := make([]int, 0, 10) // len=0, cap=10, ready to append efficientlyStep-by-step mental model for make([]int, 0, 10):
- Allocate an array of 10 ints (all zeros).
- Create a slice header pointing to that array.
- Set length to 0 (no elements “in use” yet).
- Set capacity to 10 (room to grow without reallocating).
nil slice vs empty slice
A nil slice has no underlying array. An empty slice has length 0 but is non-nil (it may or may not have an allocated array depending on how it was created).
var sNil []int // nil slice: sNil == nil is true
sEmpty := []int{} // empty slice: len==0, but sEmpty != nil
sMake := make([]int, 0) // empty slice: len==0, sMake != nilImportant behaviors:
- Comparison: slices can only be compared to
nil. You cannot dos1 == s2for slices. - len/cap:
len(nilSlice) == 0andcap(nilSlice) == 0. - append: appending to a nil slice works; it allocates as needed.
var s []int
s = append(s, 1, 2, 3) // OKJSON encoding differences (nil vs empty)
When encoding to JSON, a nil slice typically becomes null, while an empty slice becomes []. This matters for APIs where clients distinguish “missing” from “present but empty”.
// With encoding/json:
// var a []int -> "a": null
// b := []int{} -> "b": []If you want JSON arrays to always be [], ensure you initialize slices to empty rather than leaving them nil.
4) Common operations: append patterns, copy, deleting, and avoiding accidental sharing
Append patterns
Appending one slice to another uses ... to expand elements.
a := []int{1, 2}
b := []int{3, 4}
a = append(a, b...) // a is now [1 2 3 4]Building a slice in a loop should usually assign the result each time.
out := make([]int, 0, 5)
for i := 0; i < 5; i++ {
out = append(out, i*i)
}copy for controlled duplication
copy(dst, src) copies up to min(len(dst), len(src)) elements and returns the number copied.
src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
// dst is an independent copyTo copy a sub-slice into a new backing array:
base := []int{10, 20, 30, 40}
sub := base[1:3] // [20 30], shares base
independent := make([]int, len(sub))
copy(independent, sub)Deleting elements by index
To delete element at index i while preserving order, use append to stitch the parts together.
func deleteAtPreserveOrder(s []int, i int) []int {
return append(s[:i], s[i+1:]...)
}Step-by-step mental model:
s[:i]is everything before the element.s[i+1:]is everything after the element.appendconcatenates them, overwriting the removed element’s slot.
If order does not matter, you can delete in O(1) by swapping with the last element, then shortening the slice.
func deleteAtNoOrder(s []int, i int) []int {
s[i] = s[len(s)-1]
return s[:len(s)-1]
}Avoiding accidental sharing
Because slices can share an underlying array, returning a sub-slice can unintentionally keep a large array alive in memory or allow later mutations to leak across boundaries.
When you need isolation, copy into a new slice.
// Returns an independent copy of the first n elements.
func prefixCopy(s []int, n int) []int {
if n > len(s) {
n = len(s)
}
out := make([]int, n)
copy(out, s[:n])
return out
}When you want to prevent an append from modifying the original backing array, consider limiting capacity with the full slice expression before appending.
func safeAppend(base []int, extra int) []int {
view := base[:len(base):len(base)] // cap == len, forces reallocation on append
return append(view, extra)
}5) Performance-aware patterns: preallocating and when it matters
Appending repeatedly can cause multiple reallocations and copies as the slice grows. Go grows capacity automatically, but you can reduce allocations by preallocating when you have a good estimate of final size.
Preallocate with make([]T, 0, n)
// Suppose you expect about n results.
out := make([]int, 0, n)
for _, v := range input {
if v%2 == 0 {
out = append(out, v)
}
}This pattern matters most when:
- You are building large slices in hot loops.
- You can estimate the number of elements reasonably well.
- You want to reduce GC pressure by avoiding intermediate arrays.
It matters less when slices are small or the code is not performance-critical. Prefer clarity first, then optimize based on profiling.
Mini-lab 1: Filter a slice
Goal: implement a function that returns a new slice containing only the elements that match a condition.
Task
Write FilterInts that keeps only values for which keep(v) returns true.
func FilterInts(in []int, keep func(int) bool) []int {
// TODO
}Step-by-step approach
- Create an output slice with length 0.
- Optionally preallocate capacity (often
len(in)is a safe upper bound). - Loop over input; append values that pass the predicate.
- Return the output slice.
One good implementation
func FilterInts(in []int, keep func(int) bool) []int {
out := make([]int, 0, len(in))
for _, v := range in {
if keep(v) {
out = append(out, v)
}
}
return out
}Try it with different predicates:
evens := FilterInts([]int{1, 2, 3, 4, 5, 6}, func(v int) bool {
return v%2 == 0
})
big := FilterInts([]int{3, 10, 7, 25}, func(v int) bool {
return v >= 10
})Mini-lab 2: Safely remove an element by index
Goal: implement a function that removes the element at index i without panicking, and returns the updated slice plus a boolean indicating success.
Task
func RemoveAt(in []string, i int) ([]string, bool) {
// TODO
}Step-by-step approach
- Validate
i: it must be between 0 andlen(in)-1. - If invalid, return the original slice and
false. - If valid, create the result by joining
in[:i]andin[i+1:]. - Return the new slice and
true.
Implementation (preserves order)
func RemoveAt(in []string, i int) ([]string, bool) {
if i < 0 || i >= len(in) {
return in, false
}
out := append(in[:i], in[i+1:]...)
return out, true
}If you need to ensure the removed element’s storage doesn’t keep references alive (important for slices of pointers/strings in long-lived structures), you can clear the slot before reslicing.
func RemoveAtClear(in []string, i int) ([]string, bool) {
if i < 0 || i >= len(in) {
return in, false
}
copy(in[i:], in[i+1:])
in[len(in)-1] = "" // clear reference
return in[:len(in)-1], true
}