Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Go Slice Internals

Arrays, Slices, and Maps

Go provides flexible composite types for grouping data.

Arrays

An array is a fixed-size sequence of elements of a specific type.
var a [2]string
a[0] = "Hello"
a[1] = "World"
fmt.Println(a[0], a[1])
fmt.Println(a)

primes := [6]int{2, 3, 5, 7, 11, 13}
Arrays cannot be resized, and their size is part of the type: [5]int and [10]int are different types entirely. This limits their use cases, but they form the foundation for slices. In practice, you will rarely use arrays directly — think of them as the fixed-size storage that slices sit on top of.
Pitfall: Arrays in Go are value types. When you pass an array to a function or assign it to another variable, the entire array is copied. For large arrays, this is both slow and surprising for developers coming from languages where arrays are reference types:
a := [1000000]int{} // 1 million integers
b := a              // Copies all 1 million integers!
This is one reason slices exist — slices are headers (pointer, length, capacity) that reference an underlying array without copying it.

Slices

Slices are dynamically-sized, flexible views into the elements of an array. In practice, slices are much more common than arrays.
primes := [6]int{2, 3, 5, 7, 11, 13}

var s []int = primes[1:4] // [3, 5, 7]

Slice Internals

A slice does not store any data itself. It just describes a section of an underlying array. Understanding this is crucial for writing efficient Go code. Go Slice Internals A slice has three components:
  1. Pointer: Points to the first element of the slice in the underlying array.
  2. Length: The number of elements currently in the slice (accessible via len()).
  3. Capacity: The total number of elements in the underlying array from the slice’s starting point (accessible via cap()).
Why This Matters: When you slice an array or another slice, you’re creating a new slice header that points to the same underlying array. Changes to elements in one slice affect the other.
primes := [6]int{2, 3, 5, 7, 11, 13}
s1 := primes[1:4] // [3, 5, 7]
s2 := primes[2:5] // [5, 7, 11]

s1[1] = 99 // Modifies the underlying array
fmt.Println(s2[0]) // Prints 99, not 5!

Creating Slices

You can create slices using make, which allocates a new underlying array:
a := make([]int, 5)     // len=5, cap=5, [0 0 0 0 0]
b := make([]int, 0, 5)  // len=0, cap=5, []
c := []int{1, 2, 3}     // len=3, cap=3, [1 2 3]
The difference between length and capacity allows slices to grow efficiently without reallocating on every append.

Appending to Slices

The built-in append function adds elements to a slice. Here’s what happens under the hood:
  1. If len < cap: The element is added to the existing array, and length is incremented.
  2. If len == cap: A new, larger array is allocated (typically 2x the current capacity), existing elements are copied, and the new element is added.
var s []int              // len=0, cap=0
s = append(s, 0)         // len=1, cap=1 (new array allocated)
s = append(s, 1)         // len=2, cap=2 (new array allocated)
s = append(s, 2, 3, 4)   // len=5, cap=6 (new array allocated, grew to 4, then to 6)
Important: append returns a new slice value. You must assign it back:
s = append(s, 5) // Correct -- captures the potentially new slice header
append(s, 5)     // Wrong! The result is discarded -- the compiler will catch this
Performance Tip — Pre-allocate When Possible: If you know (or can estimate) the final size of a slice, pre-allocate with make to avoid repeated allocations as the slice grows:
// Without pre-allocation: multiple allocations as slice grows
var results []string
for _, item := range items {
    results = append(results, process(item))
}

// With pre-allocation: single allocation up front
results := make([]string, 0, len(items))
for _, item := range items {
    results = append(results, process(item))
}
This is especially impactful in hot loops and high-throughput code. A senior engineer would say: “always pre-allocate slices when the size is known or estimable.”
Pitfall — Slice Memory Leaks: When you sub-slice a large slice, the original backing array stays in memory even if you only need a small portion. This can cause unexpected memory retention:
// This keeps the entire original array alive in memory
func getFirstThree(data []byte) []byte {
    return data[:3] // Still references the original (possibly huge) array
}

// Fix: copy to a new, smaller slice
func getFirstThree(data []byte) []byte {
    result := make([]byte, 3)
    copy(result, data[:3])
    return result // Original array can now be garbage collected
}

Range

The range form of the for loop iterates over a slice or map.
pow := []int{1, 2, 4, 8, 16}

for i, v := range pow {
    fmt.Printf("2**%d = %d\n", i, v)
}
You can skip the index or value by assigning to _.
for _, value := range pow {
    fmt.Println(value)
}

Maps

A map maps keys to values. The zero value of a map is nil.
type Vertex struct {
    Lat, Long float64
}

var m map[string]Vertex

func main() {
    m = make(map[string]Vertex)
    m["Bell Labs"] = Vertex{
        40.68433, -74.39967,
    }
    fmt.Println(m["Bell Labs"])
}

Map Literals

var m = map[string]Vertex{
    "Bell Labs": {40.68433, -74.39967},
    "Google":    {37.42202, -122.08408},
}

Mutating Maps

m := make(map[string]int)

m["Answer"] = 42
fmt.Println("The value:", m["Answer"])

delete(m, "Answer")
fmt.Println("The value:", m["Answer"])

// Check if key exists -- the "comma ok" idiom
v, ok := m["Answer"]
fmt.Println("The value:", v, "Present?", ok)
Pitfall — Nil Map Writes: Reading from a nil map returns the zero value and is safe. Writing to a nil map causes a runtime panic:
var m map[string]int
fmt.Println(m["key"]) // Safe: returns 0 (zero value)
m["key"] = 1          // PANIC: assignment to entry in nil map

// Always initialize maps before writing:
m = make(map[string]int) // Now safe to write
m["key"] = 1
Pitfall — Map Iteration Order: Maps in Go have deliberately randomized iteration order. If you range over a map, the order of keys will differ between runs. Never rely on map ordering — if you need deterministic order, sort the keys first or use a slice of key-value pairs.
Concurrency Warning: Maps are not safe for concurrent access. If multiple goroutines read and write to the same map without synchronization, you get a runtime panic (not just a data race — Go’s runtime detects concurrent map access and crashes). Use sync.RWMutex to protect map access, or use sync.Map for specific concurrent patterns.

Interview Deep-Dive

Strong Answer:
  • A slice header is a 24-byte struct (on 64-bit systems) containing three fields: a pointer to the first element in the underlying array, an int for length (number of elements in the slice), and an int for capacity (total elements available from the pointer to the end of the underlying array).
  • When you call append, it checks if len < cap. If yes, it writes the new element into the existing backing array at position len and increments len. If len == cap, it allocates a new, larger array, copies existing elements, adds the new element, and returns a slice header pointing to the new array. The old array becomes eligible for garbage collection if nothing else references it.
  • The growth strategy is not simply “double every time.” For slices smaller than 256 elements, capacity roughly doubles. For larger slices, it grows by about 25% plus some additional headroom. The exact formula changed in Go 1.18 to produce smoother growth curves and reduce memory waste for large slices.
  • The critical implication: append may or may not return a new backing array. This is why you must always assign the result: s = append(s, x). If you forget, you may be working with a stale slice header that points to an old array.
Follow-up: Two slices share the same backing array. You append to one and it triggers a reallocation. What happens to the other slice?The other slice is completely unaffected. After reallocation, the first slice now points to a new backing array, while the second slice still points to the original. They are now independent — modifications to one do not affect the other. Before the reallocation, they shared the same memory, so modifications were visible to both. This split behavior is one of the most subtle aspects of Go slices and a frequent source of bugs. In production, if you want slices that are truly independent from the start, use copy to create a deep copy of the data. A defensive pattern is newSlice := append([]T(nil), originalSlice...) which always creates a fresh backing array.
Strong Answer:
  • The sub-slice data[:3] creates a new slice header that still points to the original 100MB backing array. As long as this 3-byte slice is reachable, the garbage collector cannot free the original 100MB. This is a classic Go memory leak that does not show up in normal testing because the memory is still technically “in use” — it is just vastly more than you need.
  • The fix is to copy the data to a new, small slice: result := make([]byte, 3); copy(result, data[:3]); return result. Now the returned slice has its own 3-byte backing array, and the original 100MB can be garbage collected when nothing else references it.
  • This pattern shows up frequently in production when reading large files or network payloads and extracting small headers, tokens, or identifiers. I have seen services leak hundreds of megabytes because of this exact pattern in log processing pipelines.
  • An alternative using append: return append([]byte(nil), data[:3]...) also creates a fresh copy. Some developers prefer this for its brevity, though copy is more explicit about the intent.
Follow-up: Go maps have deliberately randomized iteration order. Why did the Go team make this choice, and what problem does it prevent?The Go team randomized map iteration order starting in Go 1.0 to prevent developers from depending on a specific ordering that was never guaranteed by the specification. In early Go, maps happened to iterate in insertion order (or some deterministic order based on hash bucket layout), and developers wrote code that assumed this ordering. When the map implementation changed in a future Go release, those programs broke. By randomizing the iteration seed on every program run, Go forces developers to confront the non-determinism immediately rather than discovering it when the runtime changes. If you need deterministic order, sort the keys explicitly: keys := make([]string, 0, len(m)); for k := range m { keys = append(keys, k) }; sort.Strings(keys). For ordered map semantics, use a slice of key-value pairs or a third-party ordered map implementation.
Strong Answer:
  • A nil map (var m map[string]int) and an empty map (m := map[string]int{} or m := make(map[string]int)) both have length 0 and both return zero values when you read from them. But writing to a nil map causes a runtime panic, while writing to an empty map works fine. Under the hood, a nil map has no allocated hash table structure, while an empty map has an initialized but empty hash table.
  • For JSON serialization, this distinction matters: json.Marshal encodes a nil map as null and an empty map as {}. In a REST API, these have different semantic meanings. A "tags": null might mean “tags not provided” while "tags": {} means “explicitly no tags.” If your API contract requires an empty object rather than null, you must initialize your maps.
  • The idiomatic approach for API response structs is to always initialize maps in constructors or use the omitempty JSON tag. If the field is optional, use omitempty so it is omitted entirely when nil. If the field must always be present, initialize it in the constructor: return &Response{Tags: make(map[string]string)}.
  • This nil-vs-empty distinction also affects equality checks. Two nil maps are equal to each other, and two empty maps are equal to each other, but you cannot compare maps with == in Go (except to nil). You need reflect.DeepEqual or a manual comparison loop, or in Go 1.21+, maps.Equal from the standard library.
Follow-up: Go’s runtime detects concurrent map read/write and crashes with a fatal error rather than silently corrupting data. Why is a crash better than silent corruption?Silent data corruption is far worse than a crash because it propagates. A corrupted map might return wrong values that get written to a database, sent to users, or used for financial calculations. The corruption could go undetected for hours or days, and by the time you discover it, the blast radius is enormous. A crash, on the other hand, is immediate, loud, and contained. It generates a stack trace pointing to exactly where the concurrent access occurred, it is caught by monitoring systems, and it only affects the single request or goroutine. In practice, the crash makes concurrent map bugs trivially debuggable — you see “fatal error: concurrent map read and map write” with a full goroutine dump. Without the crash, you would get intermittent, non-reproducible wrong results that are nearly impossible to diagnose. The Go runtime deliberately chose fail-fast behavior to prevent the much worse outcome of data corruption at scale.