How Go Slices Work Under the Hood: What Makes Them Stand Out from Other Languages

Slices are one of the most commonly used data structures in Go.

They appear simple on the surface, but their design is carefully engineered to provide flexibility without sacrificing performance.

In this article, we will examine how Go slices work internally and analyze the algorithm that enables them to grow dynamically while remaining efficient.

What are Slices?

Slices are dynamic arrays. It uses pointers to refer to the underlying array. Further below, we will understand more in detail.

How to create slices?

Initialize the array and create a slice from it.

For creating a slice from an array, we use [start:end] notation.

start is the starting index of the slice.

end is the ending index of the slice.

import "fmt"

func main() {
    s := [5]int{10, 20, 30, 40,50}
    fmt.Println(s)
    var s1 []int = s[1:3]
    fmt.Println(s1)
}

Output:

[10 20 30 40 50]
[20 30]

Using Structs with Slices

We can store any data type in a slice.

For example, we can store a struct in a slice.

package main

import "fmt"

type Person struct {
    Name string
    Age  int
}

func main() {
    people := []Person{
 {Name: "John", Age: 30},
 {Name: "Jane", Age: 25},
 }
    fmt.Println(people)
}

The output of the code will have JSON-like output inside square brackets.

Output:

[{John 30} {Jane 25}]

So, we understood how to create slices. But to understand how Go creates and how it handles slices, we have to understand the algorithm behind it. Let's see the source code of Go. link

How Slices Work Internally?

Slices will have 3 components.

  1. Pointer to the underlying array.
  2. Length of the slice.
  3. Capacity of the slice.

It is a little bit confusing, let's understand with the example below.

[10, 20, 30, 40, 50]

Now by above image, we can understand how slices are created internally.

Modifying Slices

When we do slicing from a new slice with a different start point, it will create a new slice with a different pointer.

If we change the pointer, then the data from the previous pointer will be lost.

To identify how it works, let's see the example below.

package main

import "fmt"

func main() {
    s := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
    printSlice(s)

    s = s[1:5]
    printSlice(s)

    s = s[2:]
    printSlice(s)

    s = s[:1]
    printSlice(s)
}
func printSlice(s []int) {
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

Here is the visual representation.

Output:
This is actual output of the code.

gk@jarvis:~/exp/code/rd/go-exmaple$ go run main.go
len=10 cap=10 [1 2 3 4 5 6 7 8 9 10]
len=4 cap=9 [2 3 4 5]
len=2 cap=7 [4 5]
len=1 cap=7 [4]

I explained how empty data is represented in slices.

How Empty Slice is Represented?

package main

import "fmt"

func main() {
    var s []int
    fmt.Println(s, len(s), cap(s))
    if s == nil {
        fmt.Println("s is nil")
 }
}

Output:

[] 0 0
s is nil

len and cap are 0 because the slice is not initialized.

When we initialize a slice, it will have a defined length and capacity.

The underlying pointer will point to the first element of the backing array, and both the length and capacity will be set to the number of elements in that array.

Initializing a Slice

We can initialise slices with different data types.

Initializing a Slice with make

make is a predefined function in Go used to create and initialize slices, maps, and channels.

Reference variables like slices are typically initialized using the make command from Go's built-in functions.

package main

import "fmt"

func main() {
    a := make([]int, 5)
    fmt.Println(a, len(a), cap(a))

    b := make([]int, 5, 10)
    fmt.Println(b, len(b), cap(b))
}

Output:

[0 0 0 0 0] 5 5
[0 0 0 0 0] 5 10

This allows you to create a slice with a specific length and capacity.

make([]T, len, cap)

  • For a := make([]int, 5), it creates a slice of length 5 and capacity 5.
  • For b := make([]int, 5, 10), it creates a slice of length 5 and capacity 10.

Variable Declaration

You can also initialize a slice directly using a slice literal:

package main

import "fmt"

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

Output:

[1 2 3 4 5] 5 5

For this slice, the pointer points to the first element of the underlying array, and the length and capacity are set to the number of elements provided.
Now, we will see how to initialize 2D slices and how to append to a slice.

Initializing 2D Slices

package main

import (
    "fmt"
    "strings"
)

func main() {

    ticTacToeBoard := [][]string{
 {"_", "_", "_"},
 {"_", "_", "_"},
 {"_", "_", "_"},
 }

    ticTacToeBoard[0][0] = "X"
    ticTacToeBoard[2][2] = "O"
    fmt.Println(ticTacToeBoard)
    for i := 0; i < len(ticTacToeBoard); i++ {
        fmt.Println(strings.Join(ticTacToeBoard[i], " "))
 }
}

We are initializing a 2D slice of strings with 3 rows and 3 columns.

We can modify the values of the slice using the index variable[x][y].

Finally, we are printing the slice using a loop.

Each loop iteration prints a row of the slice.

Output:

gk@jarvis:~/exp/code/rd/go-exmaple$ go run main.go 
[[X _ _] [_ _ _] [_ _ O]]
X _ _
_ _ _
_ _ O

Appending to a Slice

Slices are dynamic arrays. We can append to a slice using the append function.

We can append any data type to a slice.

Simple example:

package main

import "fmt"

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

    a = append(a, 6)
    fmt.Println(a)
}

Output:

gk@jarvis:~/exp/code/rd/go-exmaple$ go run main.go 
[1 2 3 4 5]
[1 2 3 4 5 6]

Before we start going further, let's understand how the append function works.

As we know, slices are dynamic arrays.

When we append to a slice, the slice is resized.

Let's check how appending will resize the slice.

package main

import "fmt"

func main() {
    a := []int{1, 2, 3, 4, 5}
    printSlice(a)

    a = append(a, 6)
    printSlice(a)
}
func printSlice(s []int) {
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

So, with that context, there result should be len=6 cap=6 [1 2 3 4 5 6] after appending 6 to the slice.

Actual Output:

gk@jarvis:~/exp/code/rd/go-exmaple$ go run main.go 
len=5 cap=5 [1 2 3 4 5]
len=6 cap=10 [1 2 3 4 5 6]

Why cap is 10?

To understand this, let's see how the append function works.

Now let's see why this happens and what the reason is behind it.

The Slice Append Algorithm

In the official source code of append function, you can see here link

There are two cases for appending to a slice.

  1. Appending a single element to a slice.
  2. Appending multiple elements to a slice.

Append a single element to a slice:

package main

import "fmt"

func main() {
    a := []int{}
    printSlice(a)
    a = append(a, 0)
    printSlice(a)

    a = append(a, 1)
    printSlice(a)
    a = append(a, 2)
    printSlice(a)
    a = append(a, 3)
    printSlice(a)
    a = append(a, 4)
    printSlice(a)
    a = append(a, 5)
    printSlice(a)
    a = append(a, 6)
    printSlice(a)
}
func printSlice(s []int) {
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

This will output:

gk@jarvis:~/exp/code/rd/go-exmaple$ go run main.go
len=0 cap=0 []
len=1 cap=1 [0]
len=2 cap=2 [0 1]
len=3 cap=4 [0 1 2]
len=4 cap=4 [0 1 2 3]
len=5 cap=8 [0 1 2 3 4]
len=6 cap=8 [0 1 2 3 4 5]
len=7 cap=8 [0 1 2 3 4 5 6]

We can see clearly how the capacity is growing.

Whenever there is a match with len and cap, the next element will be appended with cap, and double the previous cap.

We didn't expect that, but let's understand the algorithm behind the append function.

How Append Works Internally


package main

import "fmt"

func main() {
    a := []int{1, 2,}
    printSlice(a)

    a = append(a, 3)
    printSlice(a)

    a = append(a, 4,5,6,7,8,9)
    printSlice(a)

    a =  append(a, 10,11,12)
    printSlice(a) 
}   
func printSlice(s []int) {
    fmt.Printf("len=%d cap=%d %v\n", len(s), cap(s), s)
}

Output:

gk@jarvis:~/exp/code/rd/go-exmaple$ go run main.go 
len=2 cap=2 [1 2]
len=3 cap=4 [1 2 3]
len=9 cap=10 [1 2 3 4 5 6 7 8 9]
len=12 cap=20 [1 2 3 4 5 6 7 8 9 10 11 12]

Append 1: Bulk append of 1,2
Inserted length is 2
Capacity is 2

Append 2: Single append of 3
Inserted length is 1
Capacity is 4
Which is double the previous capacity

Append 3: Bulk append of 4,5,6,7,8,9
Inserted length is 6
Capacity is 10
Which is double the previous capacity, but increased by 2 to round off to the bucket size

Append 4: Bulk append of 10,11,12
Inserted length is 3
Capacity is 20
Which is double the previous capacity

The underlying reasons for this behavior are:

  1. Safety Checks
  2. Zero-Size Shortcut: If the slice is empty, it skips allocation if possible.
  3. Calculating the Target Capacity:
    1. If the current capacity is small (less than 256 elements), it will double the capacity.
    2. If the current capacity is large (256 elements or more), it grows by a formula that smooths the transition from 2.0x to 1.25x. (Note: Before Go 1.18, this threshold was 1024 and grew strictly by 1.25x).
    3. If there is a bulk append and the required capacity is strictly greater than double the current capacity, it jumps straight to the exact required capacity, and even rounds up to the next size class.
  4. Byte Math and Memory Rounding (Size Classes):
    Single-element append: will double the capacity.
    Bulk append: will calculate the required capacity and round it up to the next size class.

This is a capacity growth algorithm of append function is specifically designed to minimize the number of reallocations.

Reallocations are expensive operations.

Now let's see how the loop works with the append function

Common Slices Operations

Looping with a Standard for Loop

Let's assign the power of 2 to a slice and print it.

With normal loop we can print the slice.

We basicaly loop over the length of the slice using the index.

package main

import "fmt"



var power = []int{1,2,4,8,16,32}

func main() {
    for i:=0; i<len(power); i++ {
        fmt.Printf("2^%d = %d\n", i, power[i])
 }
}

This will output:

2^0 = 1
2^1 = 2
2^2 = 4
2^3 = 8
2^4 = 16
2^5 = 32

Using Go's Built-in range Function

Go provides a very simple way to loop over a slice.

package main

import "fmt"



var power = []int{1,2,4,8,16,32}

func main() {
    for i,v := range power {
        fmt.Printf("2^%d = %d\n", i, v)
 }
}

Where i is the index and v is the value of the element.

Output:

2^0 = 1
2^1 = 2
2^2 = 4
2^3 = 8
2^4 = 16
2^5 = 32

we can also define the index as if we don't want to use it.

package main

import "fmt"



var power = []int{1,2,4,8,16,32}

func main() {
    for _,v := range power {
        fmt.Printf("%d\n", v)
 }
}

Output:

1
2
4
8
16
32

Conclusion

Go slices offer the perfect balance of developer convenience and system performance.

By understanding the relationship between the underlying array, length, and capacity—along with the clever growth algorithm Go uses to minimize expensive memory reallocations—you can write much more efficient code.

Instead of blindly appending data, you can now anticipate how your slices will grow and strategically use make to pre-allocate memory when you know your target size in advance.

Any feedback or contributors are welcome! It’s online, source-available, and ready for anyone to use.
⭐ Star it on GitHub: https://github.com/HexmosTech/git-lrc