How to Use the make Function for Slices and Maps in Go

Use the make built-in function to initialize slices with length/capacity and maps with initial capacity in Go.

The hidden allocation step

You write a Python script that appends to a list a thousand times. It works fine. You port the logic to Go, declare a slice with var items []string, and start appending. The program runs, but you notice memory usage spikes in jagged steps. Or you try to read from an index you have not filled yet and the program crashes. The difference is not your logic. It is how Go handles uninitialized collections.

In Go, declaring a variable does not allocate the underlying storage. You need to tell the runtime exactly how much space to reserve before you start writing to it. That is where make steps in. It is the bridge between a type declaration and actual memory. Without it, slices and maps are just empty shells.

What make actually does

make is the allocation factory for three specific types: slices, maps, and channels. It does not just create a variable. It builds the hidden data structure that backs the variable and hands you a reference to it.

Think of a slice like a shipping container. The container itself has a fixed physical size. The manifest on the outside says how many boxes are currently inside. When you declare a slice without make, you have a manifest that points to nothing. The container has not been built yet. make orders the container, sets its physical dimensions, and tells the manifest how many slots are officially occupied.

Maps follow the same principle but use a different internal structure. A map is a hash table. The hash table needs buckets to store key-value pairs. make builds those buckets and prepares the table for lookups. Without it, the map variable is just a nil pointer waiting to panic.

make builds the hidden structure. The variable just holds the reference.

Slices: length, capacity, and the backing array

Here is the simplest slice allocation: declare it, set length and capacity, then fill it.

package main

import "fmt"

func main() {
    // Length 3 means indices 0, 1, and 2 are initialized to zero.
    // Capacity 5 means the backing array can hold five elements before resizing.
    data := make([]int, 3, 5)

    // Writing to index 2 works because it is within the declared length.
    data[2] = 42

    // Appending adds to the end. It uses the spare capacity without allocating.
    data = append(data, 100)

    fmt.Println(data)
}

Under the hood, a slice is a small struct containing three fields: a pointer to the backing array, a length, and a capacity. make allocates the array on the heap, then fills those three fields. The length tells Go how many elements are valid for reading. The capacity tells Go how many elements fit in the allocated array.

When you assign data[2] = 42, you overwrite the zero value at that index. The runtime checks that 2 < length. If you tried data[5] = 99, the program would panic because index 5 exceeds the length, even though capacity is 5. Length controls safe access. Capacity controls allocation boundaries.

When you call append, the runtime checks if length < capacity. If true, it places the new value at the next free slot, increments the length, and returns the updated slice header. No new memory is requested. If length equals capacity, append triggers a reallocation. Go allocates a larger array, usually double the size, copies the old data, and updates the header. This copy step is what causes the jagged memory spikes you saw earlier.

Length tracks what you can read. Capacity tracks what you can write to without paying for new memory.

Maps: buckets and load factors

Maps require make before you can store anything. Here is how to pre-size one.

package main

import "fmt"

func main() {
    // Initial capacity of 10 tells the runtime to allocate enough buckets
    // to hold roughly ten key-value pairs without immediate resizing.
    scores := make(map[string]int, 10)

    // Inserting a key-value pair works immediately.
    scores["alice"] = 95
    scores["bob"] = 88

    // Reading a missing key returns the zero value for the type, not a panic.
    fmt.Println(scores["charlie"])
}

make for maps initializes the internal hash table. The capacity hint tells the runtime how many buckets to create upfront. Go's map implementation uses a load factor to decide when to grow. The hint is not a hard limit. It is a suggestion to avoid early resizing.

When you insert "alice", the runtime hashes the key, finds an empty bucket, and stores the value. Reading "charlie" returns 0 because maps never panic on missing keys. They silently return the zero value for the element type. This is a deliberate design choice. It keeps lookup code clean and avoids the need for explicit existence checks in most cases.

Maps are forgiving on reads but strict on writes. Initialize them first.

Realistic usage: pre-sizing for performance

In real code, you often know the approximate size of your data before you start processing. Pre-allocating saves CPU cycles and reduces garbage collector pressure. Here is a function that builds a lookup table from a known list of users.

package main

import "fmt"

// BuildUserIndex creates a map from user IDs to names.
// It pre-allocates the map to avoid incremental resizing during the loop.
func BuildUserIndex(count int) map[int]string {
    // Pre-size the map to match the expected number of entries.
    index := make(map[int]string, count)

    for i := 0; i < count; i++ {
        // Simulate fetching data. The map grows efficiently because
        // the initial bucket allocation matches the loop iterations.
        index[i] = fmt.Sprintf("user-%d", i)
    }

    return index
}

func main() {
    users := BuildUserIndex(1000)
    fmt.Println(users[42])
}

Without the capacity hint, Go starts with a small map. Each insertion might trigger a resize, which involves allocating a new bucket array, rehashing all existing keys, and copying values. Doing that hundreds of times wastes CPU and creates garbage. Pre-sizing aligns the allocation with the workload. The garbage collector sees one allocation instead of dozens.

The community convention is to pass the expected size when you know it. If you are reading a fixed-size CSV or deserializing a JSON array with a known length, pass that number to make. If the size is truly unknown, omit the hint. The runtime will grow the structure as needed. Over-allocating wastes memory. Under-allocating wastes CPU. Pick the middle ground.

Pre-allocation is free performance. Pay the upfront cost once instead of paying it in fragments.

Pitfalls and runtime behavior

The most common mistake is treating a nil slice like an empty one. If you declare var items []int and try items[0] = 1, the program crashes at runtime with panic: runtime error: index out of range [0] with length 0. The compiler will not catch this because nil slices are valid Go values. They just point to no backing array. You can safely call append on a nil slice. It behaves exactly like an empty slice with zero capacity. The runtime allocates the first backing array automatically. But direct indexing requires an allocated array.

Maps panic differently. Assigning to a nil map triggers panic: assignment to entry in nil map. The compiler again allows the declaration but rejects the mutation at runtime. Always initialize maps with make before storing values.

Another trap is mixing up make and new. new allocates zeroed memory and returns a pointer. make initializes the internal structure and returns the value itself. If you write m := new(map[string]int), you get a *map[string]int that points to a nil map. You still have to dereference it and call make to use it. The community convention is to skip new for slices and maps entirely. Use make for the three dynamic types, and let the zero value handle everything else.

A subtle runtime behavior involves slice headers and function boundaries. When you pass a slice to a function, Go copies the header, not the backing array. The function receives a pointer to the same underlying data. If the function appends to the slice and the capacity is sufficient, the caller sees the changes. If the function triggers a reallocation, the caller keeps the old header and misses the new data. This is why append returns the updated slice. Always reassign the result of append if you want to track growth across scope boundaries.

The compiler trusts you with nil. The runtime punishes you for assuming it means empty.

When to reach for make

Use make when you need a slice, map, or channel that is ready for immediate use. Use a nil slice when you are building a result set and plan to populate it entirely with append. Use a capacity hint when you know the approximate size upfront and want to avoid incremental reallocations. Use new when you explicitly need a pointer to a zero-valued struct or basic type, not for slices or maps. Use the zero value directly when you want an empty collection that will be replaced or populated by another function.

Pick the tool that matches your data shape. Don't over-allocate. Don't under-allocate.

Where to go next