Skip to content

Arrays, Slices, and Maps

Arrays are fixed-length, homogeneous sequences. The length is part of the type — [3]int and [5]int are different types.

var a [3]int
a[0] = 1
a[1] = 2
a[2] = 3
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3} // compiler infers length

Arrays are values. Assigning or passing an array copies the entire array:

a := [3]int{1, 2, 3}
b := a
b[0] = 99
fmt.Println(a) // [1 2 3] -- unchanged
fmt.Println(b) // [99 2 3]

Arrays are stack-allocated when they are local variables. Large arrays (> a few kilobytes) may be Better represented as slices to avoid stack frame bloat.

The built-in len returns the length:

a := [5]int{1, 2, 3, 4, 5}
fmt.Println(len(a)) // 5

Slices are dynamically-sized, flexible views into arrays. A slice is a descriptor containing a Pointer to an underlying array, a length, and a capacity:

+-------------------+
| ptr | len | cap |
+--+---+-----+------+
|
v
+---+---+---+---+---+---+
| 1 | 2 | 3 | | | |
+---+---+---+---+---+---+
+---len---+
+-------cap--------+
// Literal
s := []int{1, 2, 3}
// make with length and capacity
s := make([]int, 5) // len=5, cap=5
s := make([]int, 0, 10) // len=0, cap=10
// From an array
a := [5]int{1, 2, 3, 4, 5}
s := a[1:3] // [2, 3], len=2, cap=4

Slicing creates a new slice that references the same underlying array:

a := []int{0, 1, 2, 3, 4, 5}
s1 := a[1:4] // [1, 2, 3], len=3, cap=5
s2 := a[2:] // [2, 3, 4, 5], len=4, cap=4
s3 := a[:3] // [0, 1, 2], len=3, cap=6
s4 := a[:] // [0, 1, 2, 3, 4, 5], len=6, cap=6

Modifying a slice modifies the underlying array, which is visible through all slices sharing it:

a := []int{0, 1, 2, 3}
s := a[1:3]
s[0] = 99
fmt.Println(a) // [0 99 2 3]

append adds elements to a slice. If the capacity is exceeded, a new underlying array is allocated And all elements are copied:

s := []int{1, 2}
s = append(s, 3) // [1, 2, 3]
s = append(s, 4, 5) // [1, 2, 3, 4, 5]
s = append(s, []int{6, 7}...) // [1, 2, 3, 4, 5, 6, 7]

append may or may not allocate a new array. The return value must be captured:

s := make([]int, 0, 2)
s = append(s, 1) // len=1, cap=2 -- no reallocation
s = append(s, 2) // len=2, cap=2 -- no reallocation
s = append(s, 3) // len=3, cap=4 -- reallocation occurred

copy copies elements from a source slice to a destination slice:

src := []int{1, 2, 3}
dst := make([]int, len(src))
n := copy(dst, src)
fmt.Println(dst) // [1, 2, 3]
fmt.Println(n) // 3 -- number of elements copied

copy copies min(len(dst), len(src)) elements.

Since Go 1.21, clear zeros the elements of a slice:

s := []int{1, 2, 3}
clear(s)
fmt.Println(s) // [0, 0 0]

When append triggers reallocation, Go doubles the capacity for slices smaller than 256 Elements and grows by ~25% for larger slices. The exact strategy is an implementation detail of the Runtime and should not be relied upon.

Pre-allocate capacity when the final size is known:

// Bad: causes multiple reallocations
var s []int
for i := 0; i < 10000; i++ {
s = append(s, i)
}
// Good: single allocation
s := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
s = append(s, i)
}
var nilSlice []int // nil, len=0, cap=0
emptySlice := []int{} // not nil, len=0, cap=0
madeSlice := make([]int, 0) // not nil, len=0, cap=0

The difference matters for JSON marshaling: nil marshals to nullWhile []int{} marshals to []. For most other purposes, they are interchangeable.

Maps are hash tables mapping keys to values. The zero value is nil. A nil map is empty but Cannot be written to.

var m map[string]int // nil map
m = make(map[string]int)
m["key"] = 42
// Literal
m := map[string]int{
"a": 1,
"b": 2,
"c": 3,
}
m := map[string]int{"x": 10, "y": 20}
// Read
v := m["x"] // 10
v := m["missing"] // 0 -- zero value for missing keys (no error)
// Read with existence check
v, ok := m["x"]
if ok {
fmt.Println("found:", v)
}
// Delete
delete(m, "x")
// Length
fmt.Println(len(m))
// Iterate (order is not guaranteed)
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}

Map keys must be comparable. Comparable types are: booleans, integers, floats, strings, pointers, Interfaces (if the dynamic type is comparable), structs (if all fields are comparable), and arrays (if element type is comparable).

Slices, maps, and functions are not comparable and cannot be used as map keys.

Pre-allocate capacity when the approximate size is known:

m := make(map[string]int, 1000) // hint for ~1000 entries

Structs are aggregate types that group named fields:

type Point struct {
X, Y float64
}
type Circle struct {
Center Point
Radius float64
}
p := Point{1.0, 2.0}
p := Point{X: 1.0, Y: 2.0} // named fields (order does not matter)
p := Point{} // zero value: {0, 0}

Useful for intermediate data without defining a named type:

result := struct {
Value int
Err error
}{
Value: 42,
Err: nil,
}

Go supports type embedding (not inheritance):

type Base struct {
ID int
Name string
}
type Derived struct {
Base
Extra string
}
d := Derived{Base: Base{ID: 1, Name: "test"}, Extra: "data"}
fmt.Println(d.ID) // 1 -- promoted field
fmt.Println(d.Name) // "test" -- promoted field

Promoted fields are accessed directly on the embedding struct. This is syntactic sugar — there is No inheritance hierarchy. The embedded struct”s methods are also promoted.

Structs are comparable if all their fields are comparable:

a := Point{1.0, 2.0}
b := Point{1.0, 2.0}
fmt.Println(a == b) // true

Structs containing slices or maps are not comparable.

A slice is a window into a larger array: Imagine a long bookshelf (the underlying array). A slice is a bookmark and a page-count telling you which window of books you’re currently reading. Multiple slices can peer into the same shelf, so flipping a page in one view changes what another view sees. When you try to read past the window, Go gets a bigger shelf and copies everything over.

Why it matters: Understanding the slice-as-window mental model prevents the most common Go bugs: forgetting that append may reallocate, holding onto a tiny slice that keeps a huge array alive, or writing through one slice and surprising another.

The key insight: Slices are cheap because they’re just pointers with bounds — but that shared backing array means operations that look local can have global effects.

  1. Forgetting to capture append return value. append may allocate a new underlying array. The original slice header is not updated. Always write s = append(s, ...).

  2. Slicing retains the underlying array. A small slice of a large array prevents the large array from being garbage collected. Use copy to create an independent copy:

    small := make([]byte, len(large[1000:1100]))
    copy(small, large[1000:1100])
  3. Writing to a nil map. var m map[string]int creates a nil map. Writing to it panics. Always initialize with make or a literal before writing.

  4. Map iteration order is random. Go randomizes map iteration order. Do not rely on it. If you need ordered iteration, maintain a separate sorted key slice.

  5. Concurrent map access. Maps are not safe for concurrent use. Use sync.RWMutex or sync.Map for concurrent access.

  6. Struct embedding is not inheritance. There is no super call, no method overriding in the OOP sense. Embedding is composition with syntactic sugar for field/method promotion.

This topic covers the core concepts of arrays, slices, and maps, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • core concepts and terminology
  • algorithms and computational thinking
  • practical implementation
  • security and ethical considerations
  • applications in the real world

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.

  • Types and Variables: Scalar types that compose into slices, maps, and arrays.
  • I/O: Buffer and stream operations on byte slices and strings.
  • Interfaces: How slices and maps implement standard library interfaces.