How Pointers Work with Slices and Maps in Go

Slices and maps in Go are reference types that share underlying data, allowing functions to modify the original values directly without explicit pointers.

The mystery of the changing variable

You write a function to update a configuration map. You pass the map. You add a key. Back in main, the map has the new key. You look at the function signature. No asterisk. No pointer. You pause. In other languages, passing a variable by value creates a copy. Changes inside the function vanish when the function returns. Go seems to break that rule.

You try the same thing with a struct. You pass the struct. You modify a field. Back in main, the struct is unchanged. Go behaves normally. You switch back to slices. You pass a slice. You modify an element. The change persists. Go behaves strangely again.

The inconsistency is an illusion. Slices and maps are not the data. They are handles to the data. When you pass a slice or map, you are copying the handle, not the data. Both the caller and the function hold handles that point to the same underlying storage. The copy is cheap. The sharing is real.

The slice header and the map handle

A slice is not an array. A slice is a small descriptor that tells the program where the array lives and how much of it to use. The Go runtime represents a slice as a struct with three fields: a pointer to the backing array, the length of the slice, and the capacity of the backing array.

When you pass a slice to a function, the compiler copies those three fields. The copy gets a new pointer value, but that value is identical to the original. It points to the same memory address. The function and the caller both hold pointers to the same array. Modifying an element through either pointer affects the shared array.

Maps work similarly. A map variable holds a pointer to a hash table structure managed by the runtime. Passing a map copies that pointer. Both the caller and the function point to the same hash table. Adding, updating, or deleting keys modifies the shared table.

Think of a slice like a remote control for a television. The remote has buttons and a code. The television is the screen and speakers. When you pass a slice, you are handing over a photocopy of the remote. The function gets its own remote. Both remotes point to the same television. If the function presses "Volume Up", the television gets louder for everyone. The remote was copied, but the television is shared.

Minimal example

This example shows how a function modifies a slice element without using a pointer. The slice header is copied, but the backing array is shared.

package main

import "fmt"

// ModifyFirst sets the first element of the slice to 99.
// The slice parameter is a copy of the header, but it points to the same backing array.
func ModifyFirst(s []int) {
	// s[0] dereferences the pointer inside the slice header.
	// This writes to the shared backing array.
	s[0] = 99
}

// AddKey adds a key to the map if it does not exist.
// The map parameter is a copy of the map handle, but it points to the same hash table.
func AddKey(m map[string]int, key string) {
	// Writing to the map modifies the shared hash table.
	// The caller sees the new key immediately.
	if _, ok := m[key]; !ok {
		m[key] = 1
	}
}

func main() {
	nums := []int{1, 2, 3}
	ModifyFirst(nums)
	// nums is now [99, 2, 3].
	// The function modified the shared backing array.
	fmt.Println(nums)

	config := map[string]int{"timeout": 30}
	AddKey(config, "retries")
	// config is now map[retries:1 timeout:30].
	// The function modified the shared hash table.
	fmt.Println(config)
}

Slices copy the header, share the data.

What happens at runtime

When main calls ModifyFirst(nums), the runtime copies the slice header. The original header contains a pointer to an array at some address, say 0x1000, with length 3 and capacity 3. The copy receives the same pointer 0x1000, length 3, and capacity 3.

Inside ModifyFirst, the expression s[0] uses the pointer 0x1000 to locate the array. It writes the value 99 to the first slot of that array. The array lives in memory shared by both the caller and the function. When main accesses nums[0], it uses its own copy of the pointer 0x1000 to read the same array. It sees 99.

Maps follow the same pattern. The map variable holds a pointer to a runtime-managed structure. Passing the map copies the pointer. Both sides operate on the same structure. The runtime handles locking and resizing internally, so concurrent writes require explicit synchronization, but the sharing mechanism is the same.

The design prioritizes efficiency. Copying a slice header takes three machine words. Copying the backing array could take thousands. Passing the header is fast. Sharing the data avoids unnecessary duplication.

Reassignment breaks the link.

Realistic example: Configuration defaults

Configuration systems often use maps to store key-value pairs. A common pattern is a function that ensures default values exist. The function receives the map, checks for missing keys, and fills them in. The caller sees the updated map without needing a return value.

package main

import "fmt"

// EnsureDefaults adds missing keys to the config map.
// The map is passed by value, but the underlying hash table is shared.
// Modifications to the map content are visible to the caller.
func EnsureDefaults(config map[string]string) {
	// Check if the "timeout" key exists.
	if _, ok := config["timeout"]; !ok {
		// Add the default value.
		// This modifies the shared hash table.
		config["timeout"] = "30s"
	}

	// Check if the "retries" key exists.
	if _, ok := config["retries"]; !ok {
		config["retries"] = "3"
	}
}

func main() {
	// Initialize the map with make.
	// A nil map cannot be written to.
	userConfig := map[string]string{
		"timeout": "60s",
	}

	EnsureDefaults(userConfig)

	// userConfig now contains the default "retries" key.
	// The "timeout" key was already present, so it was not overwritten.
	fmt.Println(userConfig)
	// Output: map[retries:3 timeout:60s]
}

This pattern relies on the map being initialized. If you pass a nil map, the runtime panics when you try to write a key. The runtime panics with assignment to entry in nil map if you attempt to set a value in a nil map. You must initialize the map with make before writing, or use a pointer to a map to allow the function to initialize it.

Maps are reference types. Initialize with make.

Pitfalls: Reassignment and append

The sharing behavior applies to the data, not the handle. If a function reassigns the slice or map variable, the caller does not see the reassignment. The caller still holds the original handle.

Consider append. The append function may allocate a new backing array if the current capacity is exceeded. If allocation happens, append returns a new slice header with a pointer to the new array. The original slice header still points to the old array.

package main

import "fmt"

// AppendItem adds an item to the slice.
// append may allocate a new array if capacity is exceeded.
// If allocation happens, the returned slice has a new pointer.
// The caller's slice still points to the old array.
func AppendItem(items []int) []int {
	// Return the new slice header.
	// The caller must capture the return value to see the new element.
	return append(items, 42)
}

func main() {
	// Create a slice with length 3 and capacity 3.
	data := []int{1, 2, 3}

	// Call AppendItem but ignore the return value.
	AppendItem(data)

	// data is still [1, 2, 3].
	// append allocated a new array because capacity was full.
	// data still points to the old array.
	fmt.Println(data)

	// Capture the return value to update the slice.
	data = AppendItem(data)
	fmt.Println(data)
	// Output: [1, 2, 3, 42]
}

The same issue occurs with maps. If a function replaces the map with a new map, the caller still holds the old map.

// ReplaceMap creates a new map and assigns it to the parameter.
// The assignment only affects the local copy of the map handle.
// The caller's map variable is unchanged.
func ReplaceMap(m map[string]int) {
	// This creates a new map and assigns it to the local variable m.
	// The caller's map is not affected.
	m = map[string]int{"new": 1}
}

Reassignment severs the connection. Return the new value.

Convention: Pass slices by value

The Go community avoids pointers to slices. Writing func f(s *[]int) is rare and usually signals confusion. Slices are cheap to copy. The header is small. Passing the slice by value is idiomatic and efficient.

If you need to modify the slice header itself, such as reassigning the slice to a new backing array, use a pointer to a slice. This happens in less than 1% of cases. Most functions only modify the elements, so pass the slice by value.

The same applies to maps. Pass maps by value. Use a pointer to a map only when the function must distinguish between a nil map and an empty map for initialization, or when the function must replace the entire map.

Don't pass a *string. Strings are already cheap to pass by value. Don't pass a *[]int. Slices are already cheap to pass by value. Pass the value. Use pointers only when you need to modify the variable itself.

Pass slices by value. Save the pointer for the header.

Decision: when to use what

Use a slice value when you need to read or modify the elements inside the slice. Use a pointer to a slice when the function must reassign the slice variable itself, such as when the caller needs to see a completely new backing array. Use a map value when you need to add, update, or delete keys in the map. Use a pointer to a map when the function must replace the entire map with a new one, or when you need to initialize a nil map inside the function. Use a plain value type like int or string when the data is small and you don't need shared state.

Slices and maps are handles. Copy the handle, share the data.

Where to go next