DEV Community

Cover image for The Secret Life of Go: Arrays and Slices
Aaron Rose
Aaron Rose

Posted on

The Secret Life of Go: Arrays and Slices

Chapter 4: Collections and the Art of Growing Lists

Thursday morning brought rain. Ethan arrived at the archive shaking water from his jacket, balancing a coffee tray and a small pastry box.

Eleanor glanced up from her laptop. "Wet out there?"

"November in New York." He set down the coffees and opened the box. "Rugelach today. Chocolate and cinnamon."

"You're learning the neighborhood." She took one and bit into it. "What made you choose rugelach?"

"The baker said they're good for thinking. Something about the layers."

Eleanor smiled. "Layers. Perfect. Today we're talking about collections—how to store multiple pieces of data. And yes, there are layers to this story."

She opened a new file. "Tell me, Ethan. If I asked you to store five numbers, how would you do it?"

"Five variables?"

"You could. num1, num2, num3, num4, num5. What if I asked for a hundred numbers?"

"That would be... painful."

"Exactly. So every language has collections—ways to store groups of related data. Go has two: arrays and slices. Let's start with arrays."

package main

import "fmt"

func main() {
    var numbers [5]int
    numbers[0] = 10
    numbers[1] = 20
    numbers[2] = 30
    numbers[3] = 40
    numbers[4] = 50

    fmt.Println(numbers)
    fmt.Println("First number:", numbers[0])
    fmt.Println("Length:", len(numbers))
}
Enter fullscreen mode Exit fullscreen mode

"An array in Go is a fixed-size collection. Look at the declaration: var numbers [5]int. The [5] means this array holds exactly five integers. Not four. Not six. Five."

Ethan studied the code. "And we access them with brackets?"

"Yes. Arrays are zero-indexed—the first element is at position 0, the last at position 4. Think of it like floors in a building if the ground floor is floor zero."

Eleanor ran the code:

[10 20 30 40 50]
First number: 10
Length: 5
Enter fullscreen mode Exit fullscreen mode

"The len function returns the length—how many elements the array holds. For arrays, this never changes. It's fixed at compile time."

"What happens if I try to add a sixth number?"

"The compiler stops you. Watch:" She typed:

numbers[5] = 60  // This won't compile
Enter fullscreen mode Exit fullscreen mode

"Go will say: 'index out of range.' Arrays are rigid. Their size is part of their type—[5]int is a different type from [6]int. They're not interchangeable."

Ethan frowned. "That seems... restrictive?"

"It is. And that's why you almost never use arrays in Go." Eleanor closed the file. "Arrays exist, they're the foundation, but in practice, you use slices. Let me show you."

She opened a new file:

package main

import "fmt"

func main() {
    numbers := []int{10, 20, 30, 40, 50}

    fmt.Println(numbers)
    fmt.Println("Length:", len(numbers))

    numbers = append(numbers, 60)
    fmt.Println("After append:", numbers)
    fmt.Println("New length:", len(numbers))
}
Enter fullscreen mode Exit fullscreen mode

"Look at the declaration: numbers := []int{10, 20, 30, 40, 50}. No size in the brackets. That's a slice."

"What's the difference?"

"A slice is a dynamic view into an array. It can grow. It can shrink. It's flexible." Eleanor ran the code:

[10 20 30 40 50]
Length: 5
After append: [10 20 30 40 50 60]
New length: 6
Enter fullscreen mode Exit fullscreen mode

"See? We added a sixth element with append, and the slice grew. No compiler error. No fixed size. Notice how we assign the result back: numbers = append(numbers, 60). Always do this—append may return a slice with a different backing array."

Ethan leaned forward. "So slices are just better arrays?"

"Not quite. Let me show you what's really happening." Eleanor drew on her notepad:

Slice:           [10, 20, 30]
                  ↓   ↓   ↓
Backing Array:   [10, 20, 30, __, __, __]
                  ↑           ↑
                  start       capacity
Enter fullscreen mode Exit fullscreen mode

"A slice is actually three pieces of information: a pointer to an underlying array, a length (how many elements you're using), and a capacity (how many elements the backing array can hold before it needs to grow)."

She typed a new example:

package main

import "fmt"

func main() {
    numbers := make([]int, 3, 6)
    fmt.Println("Slice:", numbers)
    fmt.Println("Length:", len(numbers))
    fmt.Println("Capacity:", cap(numbers))

    numbers[0] = 10
    numbers[1] = 20
    numbers[2] = 30

    fmt.Println("\nAfter setting values:")
    fmt.Println("Slice:", numbers)

    numbers = append(numbers, 40, 50, 60)
    fmt.Println("\nAfter appending 3 elements:")
    fmt.Println("Slice:", numbers)
    fmt.Println("Length:", len(numbers))
    fmt.Println("Capacity:", cap(numbers))

    numbers = append(numbers, 70)
    fmt.Println("\nAfter appending 1 more element:")
    fmt.Println("Slice:", numbers)
    fmt.Println("Length:", len(numbers))
    fmt.Println("Capacity:", cap(numbers))
}
Enter fullscreen mode Exit fullscreen mode

"make([]int, 3, 6) creates a slice with length 3 and capacity 6. The three elements start at zero—Go's default value for integers. We can use three elements immediately, but the backing array has room for six."

Eleanor ran the code:

Slice: [0 0 0]
Length: 3
Capacity: 6

After setting values:
Slice: [10 20 30]

After appending 3 elements:
Slice: [10 20 30 40 50 60]
Length: 6
Capacity: 6

After appending 1 more element:
Slice: [10 20 30 40 50 60 70]
Length: 7
Capacity: 12
Enter fullscreen mode Exit fullscreen mode

"Watch the capacity. We start with 6. After we fill it and append one more element, capacity jumps to 12. Go automatically allocated a bigger backing array and copied the data over."

"Why not just grow by one each time?"

"Performance. Allocating memory is expensive. Copying data is expensive. So Go grows the backing array strategically—it grows more aggressively for small slices, less aggressively for large ones. You pay the cost occasionally rather than constantly."

Let's visualize what happened:

Initial state: make([]int, 3, 6)
Slice length: 3, Capacity: 6
[10, 20, 30, __, __, __]
 ↑           ↑
 used        available

After append(numbers, 40, 50, 60):
Slice length: 6, Capacity: 6
[10, 20, 30, 40, 50, 60]
 ↑                      ↑
 used (full capacity)

After append(numbers, 70):
Go allocates new backing array (capacity grows strategically)
Slice length: 7, Capacity: 12
[10, 20, 30, 40, 50, 60, 70, __, __, __, __, __]
 ↑                          ↑
 used                       available
Enter fullscreen mode Exit fullscreen mode

Ethan traced through the diagram. "So when I append, I'm not always allocating new memory?"

"Exactly. If there's room in the backing array, append just adds the element and updates the length. When there's no room, it allocates a bigger array, copies everything over, then adds the element. That's why you always assign the result back—append might return a completely new slice."

"That sounds complicated."

"It is. But you don't have to think about it. append handles the complexity. You just write numbers = append(numbers, 70) and Go does the right thing."

Eleanor poured more coffee. "Now, here's something subtle. Watch this:"

package main

import "fmt"

func main() {
    original := []int{10, 20, 30}
    fmt.Println("Original:", original)

    slice1 := original[0:2]
    slice2 := original[1:3]

    fmt.Println("Slice1 (elements 0-1):", slice1)
    fmt.Println("Slice2 (elements 1-2):", slice2)

    slice1[1] = 999

    fmt.Println("\nAfter modifying slice1[1]:")
    fmt.Println("Original:", original)
    fmt.Println("Slice1:", slice1)
    fmt.Println("Slice2:", slice2)
}
Enter fullscreen mode Exit fullscreen mode

"This is called slicing—creating a new slice from an existing one. original[0:2] means 'give me a slice starting at index 0, up to but not including index 2.'"

She ran the code:

Original: [10 20 30]
Slice1 (elements 0-1): [10 20]
Slice2 (elements 1-2): [20 30]

After modifying slice1[1]:
Original: [10 999 30]
Slice1: [10 999]
Slice2: [999 30]
Enter fullscreen mode Exit fullscreen mode

Ethan stared at the output. "They're all connected?"

"They share the same backing array. When we modified slice1[1], we changed the second element of the backing array. So original and slice2 see the change too—they're all views into the same underlying data."

Eleanor drew another diagram:

                original
              [10, 20, 30]
               ↓   ↓   ↓
Backing Array: [10, 20, 30]
               ↓   ↓
            slice1: [10, 20]
                   ↓   ↓
                slice2: [20, 30]

After slice1[1] = 999:
Backing Array: [10, 999, 30]
               ↓    ↓
            slice1: [10, 999]
                        ↓   ↓
                   slice2: [999, 30]
               ↓    ↓    ↓
              original: [10, 999, 30]
Enter fullscreen mode Exit fullscreen mode

"This is powerful but dangerous. If you're not careful, modifying one slice can change another. Go's philosophy is to give you the power and trust you to use it wisely."

"How do I avoid that?"

"If you need an independent copy, use the copy function:"

package main

import "fmt"

func main() {
    original := []int{10, 20, 30}
    independent := make([]int, len(original))
    copy(independent, original)

    independent[1] = 999

    fmt.Println("Original:", original)
    fmt.Println("Independent:", independent)
}
Enter fullscreen mode Exit fullscreen mode

Eleanor ran it:

Original: [10 20 30]
Independent: [10 999 30]
Enter fullscreen mode Exit fullscreen mode

"Now they're separate. We allocated a new slice with make, then used copy to duplicate the data. Changes to one don't affect the other."

Let's look at common slice operations:

package main

import "fmt"

func main() {
    // Creating slices
    slice1 := []int{1, 2, 3}           // Literal
    slice2 := make([]int, 5)            // Make with length 5
    slice3 := make([]int, 3, 10)        // Make with length 3, capacity 10

    // Appending (always assign back!)
    slice1 = append(slice1, 4, 5, 6)

    // Slicing
    subset := slice1[1:4]               // Elements 1, 2, 3

    // Iterating
    for i := 0; i < len(slice1); i++ {
        fmt.Println("Index:", i, "Value:", slice1[i])
    }

    // Range (cleaner iteration)
    for index, value := range slice1 {
        fmt.Println("Index:", index, "Value:", value)
    }

    // Range, ignoring index
    for _, value := range slice1 {
        fmt.Println("Value:", value)
    }
}
Enter fullscreen mode Exit fullscreen mode

"The range keyword is beautiful. It iterates over a slice, giving you both the index and the value. If you don't need the index, use _ to ignore it."

Ethan typed along. "Why underscore?"

"In Go, if you declare a variable and don't use it, the compiler complains. But _ is the 'blank identifier'—it means 'I know there's a value here, but I'm explicitly ignoring it.' It's honest code."

Eleanor closed her laptop. "That's enough for today. Arrays exist but you rarely use them. Slices are the workhorse—dynamic, flexible, efficient. They're backed by arrays, they can share data, and append handles growth automatically."

She finished her rugelach. "Next time: maps. How Go lets you store key-value pairs."

Ethan gathered the cups. "Eleanor?"

"Yes?"

"Why have arrays at all if slices are better?"

Eleanor smiled. "Performance. Slices have a tiny bit of overhead—that three-piece structure I mentioned. For most code, it doesn't matter. But in performance-critical code—game engines, scientific computing, low-level systems—arrays can be faster because they're just raw memory, no indirection."

"So it's about giving you the choice?"

"Always. Go gives you the simple tool that works 99% of the time, and the precise tool for when you need control. Arrays are precise. Slices are practical. Most code uses slices. But when you need every nanosecond, arrays are there."

Ethan climbed the stairs, thinking about layers and backing arrays and the way data could share space without you realizing. In Python, lists just grew. In Go, growth had a mechanism, visible and controllable.

Maybe that was the pattern again: Go showed you the machinery. It didn't hide the cost of operations. And in return, you could reason about performance, about memory, about what your code actually did.


Key Concepts from Chapter 4

Arrays: Fixed-size collections declared with [n]type. Size is part of the type. Rarely used directly in Go.

Slices: Dynamic collections declared with []type. The practical way to work with sequences in Go.

Slice structure: Three components—pointer to backing array, length (number of elements), capacity (size of backing array).

make function: make([]type, length, capacity) creates a slice. Capacity is optional; if omitted, it equals length.

append function: append(slice, elements...) adds elements to a slice. Returns a new slice (may have new backing array if it grew). Always assign the result back.

Slicing operation: slice[start:end] creates a new slice viewing part of an existing slice. Shares the backing array.

len and cap: len(slice) returns current number of elements. cap(slice) returns capacity of backing array.

copy function: copy(destination, source) copies elements from one slice to another. Creates independent data.

range keyword: Iterates over slices (and other collections). Returns index and value: for i, v := range slice.

Blank identifier: _ explicitly ignores a value. Required when range gives you values you don't need.

Shared backing arrays: Multiple slices can view the same underlying array. Modifying one can affect others.

Zero values: Slices created with make are initialized with the zero value of their type (0 for int, "" for string, etc.).


Next chapter: Maps—where Ethan learns about key-value pairs, and Eleanor explains why Go's map syntax is so clean.


Aaron Rose is a software engineer and technology writer at tech-reader.blog and the author of Think Like a Genius.

Top comments (0)