How to Create and Initialize a Slice in Go

Create a Go slice using the make function for specific capacity or slice literals for immediate values.

You need a list that grows

You are building a log parser that reads a stream of events. You need to store the errors you find so you can report them at the end. You don't know if there will be three errors or three thousand. In C, you would allocate a fixed array and risk overflow, or implement a linked list and suffer from cache misses and pointer chasing. In Java, you would create an ArrayList and accept the overhead of object headers, virtual dispatch, and auto-boxing for primitives.

Go gives you the slice. A slice combines the speed of a contiguous array with the flexibility of a dynamic list. You get predictable performance, zero garbage collection pressure for the container itself, and a syntax that feels natural. Slices are the default collection type in Go. You will use them everywhere.

A slice is a window, not a box

A slice is not an array. A slice is a lightweight descriptor that points to an underlying array. The array holds the actual data. The slice holds three pieces of information: a pointer to the start of the array, the length of the slice, and the capacity of the slice.

Think of the array as a row of lockers in a school hallway. The slice is a key card. The key card tells the security system which locker to start at, how many lockers you are allowed to access, and how many lockers exist in that row before the wall ends. You pass the key card around, not the lockers. The lockers never move. Only the key card changes.

The length controls what you can see. The capacity controls how much space is reserved for growth. When you add items and the length hits the capacity, the runtime allocates a new, larger array, copies the data, and updates the slice. This happens automatically. You don't manage the memory. You just use the slice.

Creating slices from scratch

Go provides three main ways to create a slice: literals, the make function, and nil declaration. Each serves a different purpose.

Here are the standard patterns for initialization.

package main

import "fmt"

func main() {
    // Literal creates a slice with length and capacity equal to the number of elements.
    // The compiler allocates the backing array automatically.
    scores := []int{10, 20, 30}

    // make creates a slice with zeroed values.
    // Length is 3, capacity is 5. Two slots are reserved for future growth.
    buffer := make([]byte, 3, 5)

    // Empty slice literal. Length 0, capacity 0.
    // Useful as a default value when no data exists yet.
    empty := []string{}

    fmt.Println(scores, buffer, empty)
}

Literals are fast and concise. Use them when you know the values at compile time. The compiler embeds the data directly. make is flexible. Use it when you need a pre-sized buffer or want to separate length from capacity. The make function allocates the array, fills it with zero values, and returns the slice header.

Nil slices are the zero value for slice types. You declare them with var s []T. A nil slice has length 0 and capacity 0. It behaves like an empty slice for most operations. len(nilSlice) returns 0. append works on nil slices. The distinction matters when encoding to JSON. A nil slice serializes to null. An empty slice []int{} serializes to []. Use a nil slice when you have no data and want to save memory. Use an empty slice literal when the absence of data is distinct from no data, or when the API expects a non-null JSON array.

Literals are fast. Make is flexible. Nil is zero.

What happens under the hood

When you write []int{1, 2, 3}, the compiler sees the literal. It allocates an array of size 3 on the heap, copies the constants 1, 2, and 3 into that array, and creates a slice header pointing to index 0 with length 3 and capacity 3. The slice header is a small struct that fits in a few machine words. You can pass it to a function by value. The function gets a copy of the header, not a copy of the data. The data stays in one place.

When you call make([]int, 3, 5), the runtime allocates an array of size 5. It fills all 5 slots with the zero value for int, which is 0. It creates a slice header pointing to index 0 with length 3 and capacity 5. The length controls what you can see. The capacity controls what you can grow into without reallocating.

The len and cap built-ins return the length and capacity from the slice header. They are cheap operations. They just read fields. They do not traverse the data.

Slicing an existing slice

You can create a new slice from an existing slice using the slice operator s[low:high]. This creates a new slice header that points to the same backing array. The new slice starts at index low and ends before index high. The length is high - low. The capacity is the original capacity minus low.

Slicing is a powerful way to view subsets of data without copying. It is also a source of subtle bugs. The new slice shares the backing array with the original. Changes to one slice can affect the other.

Here is how slicing works and how aliasing manifests.

package main

import "fmt"

func main() {
    // Original slice with length 5 and capacity 5.
    data := []int{10, 20, 30, 40, 50}

    // Slice from index 1 up to, but not including, index 3.
    // The new slice shares the backing array with data.
    subset := data[1:3]

    // Modify the subset. The change is visible in the original data.
    subset[0] = 99

    fmt.Println(data)
    fmt.Println(subset)
}

The output shows that data now contains 99 at index 1. The subset and the original share the same memory. This is efficient. It avoids copying. It also means you must be careful. If you pass a subset to a function and the function modifies the data, the original changes. This is usually what you want. It allows functions to work on chunks of data without allocating new buffers.

Slices share data. Copy to isolate.

Growing slices with append

The append built-in adds elements to a slice. It checks the capacity. If there is room, it writes the new element and increments the length. If there is no room, it allocates a new array with larger capacity, copies the old data, writes the new element, and returns a new slice header.

The growth strategy is geometric. The runtime typically doubles the capacity when it needs to grow. This ensures that append is amortized O(1). You can append many elements without worrying about performance.

Here is a realistic example of collecting data with append.

package main

import "fmt"

// CollectErrors filters a list and returns only problematic entries.
// It shows how append manages growth automatically.
func CollectErrors(items []string) []string {
    // Declare nil slice. Length 0, capacity 0.
    // append handles the first allocation efficiently.
    var results []string

    for _, item := range items {
        if len(item) > 3 {
            // append returns the updated slice.
            // Always assign the result back to the variable.
            results = append(results, item)
        }
    }

    return results
}

func main() {
    data := []string{"a", "longer", "b", "very long string"}
    fmt.Println(CollectErrors(data))
}

A common mistake is calling append without capturing the return value. append(items, newItem) does nothing to items. The compiler allows this because append is a function that returns a value. You must write items = append(items, newItem). If you forget, the data vanishes. The slice remains unchanged.

append can also take multiple arguments or another slice. append(s, other...) appends all elements of other to s. The ... operator unpacks the slice. This is useful for merging data.

Append returns a new slice. Capture the result.

Pitfalls and runtime errors

Slices are simple, but they have traps. The most common trap is aliasing. When you slice a slice, the new slice shares the backing array. If you append to the new slice and it grows within the original capacity, you overwrite data in the original slice.

Here is the aliasing bug in action.

package main

import "fmt"

func main() {
    // Slice with extra capacity.
    // Length is 3, capacity is 5.
    original := make([]int, 3, 5)
    for i := 0; i < 3; i++ {
        original[i] = i * 10
    }

    // Slice to get the last element.
    // Length is 1, capacity is 2.
    tail := original[2:]

    // Append to tail. Capacity allows growth without reallocation.
    // This writes into the shared backing array.
    tail = append(tail, 999)

    // The original slice now sees the new value.
    // This is often a bug when you expect tail to be independent.
    fmt.Println(original)
    fmt.Println(tail)
}

The output shows original as [0 10 20 999 0]. The tail append wrote into the reserved space of original. This is a classic bug. The fix is to break the aliasing. You can use the copy function to create an independent copy. Or you can use a three-index slice s[low:high:max] to limit the capacity of the new slice. The three-index slice sets the capacity to max - low. This prevents the new slice from growing into the original's data.

Another pitfall is index out of range. Accessing s[i] where i >= len(s) causes a runtime panic. The compiler cannot check this at compile time because lengths are dynamic. The runtime checks bounds on every access. The panic message is runtime error: slice bounds out of range [5] with length 3. This stops the program immediately. Use len to check bounds before access, or use range to iterate safely.

Creating a slice with negative length or capacity also panics. make([]int, -1) triggers runtime error: makeslice: len out of range. The compiler catches some obvious cases, but dynamic values slip through. Validate inputs before calling make.

Nil is zero. Empty is explicit. Pick the one that matches your intent.

When to use each approach

Slices are versatile. You choose the creation method based on what you know about the data.

Use a slice literal []T{v1, v2} when you know the initial values at compile time.

Use make([]T, length, capacity) when you need a pre-sized buffer and want to control the initial capacity to avoid reallocations.

Use make([]T, length) when you need a slice of zeroed values with length equal to capacity.

Use var s []T to declare a nil slice when you are building up data with append and don't know the size in advance.

Use []T{} to create an empty non-nil slice when you need to distinguish between "no data" and "empty list" in JSON or API responses.

Use a three-index slice s[low:high:max] when you need to limit the capacity of a sub-slice to prevent aliasing bugs.

Slices grow. Arrays don't. Use slices for almost everything.

Where to go next