DEV Community

Cover image for What every Go developer wishes they knew before their first production outage
Stanley Chege Thuita
Stanley Chege Thuita

Posted on

What every Go developer wishes they knew before their first production outage

The Slice Trap That Cost Me 4 Hours (And How to Never Fall Into It Again)

You've been there. The bug that makes no sense. The data that changes when you weren't looking. The append that silently created a monster.

Let me tell you about a Tuesday night that still haunts me.

The API was returning duplicate items. Not always. Just sometimes. Just when the payload was over a certain size. Just when the moon was in the seventh house.

Four hours. Four developers. One slack thread with 47 messages.

The culprit? A slice. Just a slice. Innocent-looking. Cute, even. And completely misunderstood by all four of us.

Today, you learn what we learned. And you never make these mistakes again.

Part 1: The Slice Lie They Tell You

Most tutorials teach slices like this:

"A slice is a dynamically-sized array."

This is not wrong, but it's like saying "a car is a metal box with wheels." Technically true. Completely useless when the engine fails.

Here's what a slice actually is:

// This is NOT a slice
numbers := []int{1, 2, 3}

// This is a slice:
// A tiny struct containing:
// - A pointer to an underlying array
// - A length (len)
// - A capacity (cap)
Enter fullscreen mode Exit fullscreen mode

Visualize it:

slice := []int{1, 2, 3}

┌─────────────────────────────────────┐
│  slice (24 bytes on 64-bit arch)    │
│  ┌──────────┬──────────┬──────────┐ │
│  │  ptr ────┼─▶ [1][2][3]          │ │
│  │  len = 3 │          ↑            │ │
│  │  cap = 3 │    underlying array   │ │
│  └──────────┴──────────┴──────────┘ │
└─────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

The pointer is the dangerous part. Because when you copy a slice, you copy the pointer. Not the data.

original := []int{1, 2, 3}
copycat := original  // This copies the struct (ptr, len, cap)

copycat[0] = 99

fmt.Println(original[0])  // 99 !!! The original changed!
Enter fullscreen mode Exit fullscreen mode

Both variables point to the same underlying array. This is intentional. This is powerful. This is also the source of 90% of slice bugs.

Part 2: The 4 Slice Traps That Will Bite You

Trap #1: The Append That Betrayed You

func main() {
    original := []int{1, 2, 3}
    addElement(original)
    fmt.Println(original)  // Still [1 2 3] ??? Where did 4 go?
}

func addElement(s []int) {
    s = append(s, 4)  // This doesn't modify the original!
}
Enter fullscreen mode Exit fullscreen mode

Why this fails:

append returns a new slice header. It might point to the same underlying array (if capacity allows) or a completely new array (if capacity is exceeded). But the s inside the function is a copy of the header. Modifying it doesn't affect the caller's variable.

The fix:

func addElement(s []int) []int {
    return append(s, 4)
}

// Or use a pointer
func addElement(s *[]int) {
    *s = append(*s, 4)
}
Enter fullscreen mode Exit fullscreen mode

The Rule: If a function modifies a slice (length, capacity, or elements), either return the new slice or use a pointer.

Trap #2: The Shared Array That Ate My Weekend

This is the production killer.

func main() {
    users := []User{
        {Name: "Alice"},
        {Name: "Bob"},
        {Name: "Charlie"},
    }

    // Store references to "admin" users
    var admins [][]User

    for i := 0; i < 3; i++ {
        subset := users[:i+1]  // Take a subset
        admins = append(admins, subset)
    }

    // Later... modify a user
    users[1].Name = "ROBERT"

    fmt.Println(admins[1][1].Name)  // "ROBERT" ??? Wait, what?
}
Enter fullscreen mode Exit fullscreen mode

What happened:

Every subset shared the same underlying array as users. When you changed users[1], every slice that included that index changed too.

The fix: Force a copy when you need independence.

subset := make([]User, i+1)
copy(subset, users[:i+1])
admins = append(admins, subset)
Enter fullscreen mode Exit fullscreen mode

The Rule: If you need independence, copy. If you want sharing (for performance), keep the reference. But know which one you have.

Trap #3: The Capacity Confusion

func main() {
    s1 := make([]int, 3, 3)  // len=3, cap=3
    s2 := append(s1, 4)      // len=4, cap=6 (new array!)

    s1[0] = 99

    fmt.Println(s2[0])  // Still 0, not 99. Different arrays now.
}
Enter fullscreen mode Exit fullscreen mode

Append creates a new array when capacity is exceeded. Your slices diverge silently.

The Rule: After any append, treat the result as potentially different from the original. Never assume they share memory.

Trap #4: The Loop Variable Reuse (Classic)

func main() {
    users := []string{"Alice", "Bob", "Charlie"}
    var pointers []*string

    for _, name := range users {
        pointers = append(pointers, &name)  // Same &name every time!
    }

    for _, p := range pointers {
        fmt.Println(*p)  // Charlie Charlie Charlie
    }
}
Enter fullscreen mode Exit fullscreen mode

The horror: The loop variable name is reused. Its address never changes. Every pointer points to the same memory location, which ends up holding the last value.

The fix:

for _, name := range users {
    n := name  // Create a new variable
    pointers = append(pointers, &n)
}
Enter fullscreen mode Exit fullscreen mode

Or use index:

for i := range users {
    pointers = append(pointers, &users[i])
}
Enter fullscreen mode Exit fullscreen mode

Part 3: The Mental Model That Fixes Everything

Stop thinking of slices as "dynamic arrays." Think of them as window views.

Imagine your underlying array is a long street of houses:

[H0][H1][H2][H3][H4][H5][H6][H7]  ← Underlying array
Enter fullscreen mode Exit fullscreen mode

A slice is a window that looks at a contiguous section:

slice1 := array[2:5]  → window looking at [H2][H3][H4]
slice2 := array[3:6]  → window looking at [H3][H4][H5]
Enter fullscreen mode Exit fullscreen mode

Key insights from this model:

  • Two windows can look at the same house. Change H3, both windows see it.
  • Extending a window (appending) might require moving to a new street if the current street has no more houses to the right.
  • Copying a window doesn't copy the houses. Just the view.

Once you see slices as windows, every behavior makes sense.

Part 4: The Cheat Sheet You'll Actually Use

When to Copy

Scenario Copy? Why
Returning a slice from a function that modifies it Caller expects independence
Storing a slice for later use when original might change Prevent accidental sharing
Filtering data that will be modified later Clean separation
Performance-critical code where copying is expensive But document the sharing!

The Copy Pattern

// The safe way to copy a slice
func copySlice(original []int) []int {
    result := make([]int, len(original))
    copy(result, original)
    return result
}

// Or for one line:
result := append([]int(nil), original...)
Enter fullscreen mode Exit fullscreen mode

The Append Pattern (Always Use This)

// BAD: Assuming append modifies in place
func add(s []int, v int) {
    s = append(s, v)  // Caller won't see this
}

// GOOD: Return the new slice
func add(s []int, v int) []int {
    return append(s, v)
}

// GOOD: Use pointer
func add(s *[]int, v int) {
    *s = append(*s, v)
}

// BEST: Explicit naming
func appendInt(s []int, v int) []int {
    return append(s, v)
}
Enter fullscreen mode Exit fullscreen mode

Part 5: Real-World Examples From Production

Example 1: The Pagination Bug

// Before: The bug
func paginate(items []Item, page, size int) []Item {
    start := page * size
    end := start + size

    if start >= len(items) {
        return []Item{}
    }
    if end > len(items) {
        end = len(items)
    }

    // DANGER: This shares the underlying array
    return items[start:end]
}

// Later code modifies the paginated results...
// And the original changes!
Enter fullscreen mode Exit fullscreen mode
// After: The fix
func paginate(items []Item, page, size int) []Item {
    start := page * size
    end := start + size

    if start >= len(items) {
        return []Item{}
    }
    if end > len(items) {
        end = len(items)
    }

    // Safe: Create independent copy
    result := make([]Item, end-start)
    copy(result, items[start:end])
    return result
}
Enter fullscreen mode Exit fullscreen mode

Example 2: The Batch Processor

type Batch struct {
    Items []Item
}

func (b *Batch) Process() {
    // DANGER: This batch owns the slice
    // But Process is about to modify it
    for i := range b.Items {
        b.Items[i].Processed = true
    }
}

func (b *Batch) Clone() *Batch {
    // Safe copy for concurrent processing
    newItems := make([]Item, len(b.Items))
    copy(newItems, b.Items)
    return &Batch{Items: newItems}
}
Enter fullscreen mode Exit fullscreen mode

Part 6: The Expert-Level Slice Optimization

When you're ready to go deep, understand capacity planning.

// Bad: Multiple reallocations
func collect(results chan int) []int {
    var collected []int  // cap=0
    for val := range results {
        collected = append(collected, val)  // Might reallocate many times
    }
    return collected
}

// Good: Preallocate capacity (if you know or can guess)
func collect(results chan int) []int {
    collected := make([]int, 0, 100)  // cap=100
    for val := range results {
        collected = append(collected, val)  // No allocations until >100
    }
    return collected
}

// Expert: Geometric growth
func collect(results chan int) []int {
    var collected []int
    for val := range results {
        collected = append(collected, val)
        if cap(collected) == len(collected) && len(collected) > 0 {
            // At capacity, pre-allocate next size
            newCap := cap(collected) * 2
            newSlice := make([]int, len(collected), newCap)
            copy(newSlice, collected)
            collected = newSlice
        }
    }
    return collected
}
Enter fullscreen mode Exit fullscreen mode

But Go already does geometric growth (typically doubling up to 1024, then ~1.25x after). So honestly? Just use append and trust it, unless you've measured a performance problem.

This Week's Challenge

Find every slice in your current Go code. For each one, ask:

  1. Who owns the underlying array? (Who can modify it?)
  2. Is this slice shared? (Passed to functions? Stored in structs?)
  3. Could an append cause unexpected behavior? (Is the result assigned back?)

If you can't answer these for any slice, you have a potential bug. Fix it now.

Next Week Preview

"Context: The Most Misunderstood 73 Lines in Go"

We'll dissect Go's context package — why it's not just for timeouts, how to use it correctly, and the one pattern that causes nil pointer panics in production.

Until then, remember: A slice is a window, not a closet. Looking through the same window, you see the same things.

What Readers Learned Last Week

"I've been writing Go for 3 years and never consciously thought about 'ownership.' This changed how I design everything." — Miguel, Platform Engineer

"The ownership/flow mental model made channels finally click for me." — Priya, Backend Developer

"I found 3 slice bugs in my codebase after reading this. THREE." — Thomas, Tech Lead

Share this with a Go developer who has ever said "but it worked on my machine" after a slice bug.

Your Go Gazette will be in your inbox next Friday. Same Go time. Same Go channel.

Appendix: Slice Debugging Quick Reference

Common Slice Mistakes and Fixes

// MISTAKE 1: Forgetting append returns new slice
mySlice := []int{1, 2, 3}
append(mySlice, 4)  // ❌ result discarded
// Fix:
mySlice = append(mySlice, 4)  // ✅

// MISTAKE 2: Sharing slices unintentionally
sliceA := []int{1, 2, 3}
sliceB := sliceA
sliceB[0] = 99
fmt.Println(sliceA[0])  // 99 (unexpected)
// Fix: Explicit copy
sliceB := make([]int, len(sliceA))
copy(sliceB, sliceA)

// MISTAKE 3: Taking address of loop variable
for _, v := range slice {
    go func() {
        fmt.Println(v)  // ❌ v is reused
    }()
}
// Fix:
for _, v := range slice {
    v := v  // Shadow with new variable
    go func() {
        fmt.Println(v)  // ✅
    }()
}

// MISTAKE 4: Assuming capacity is sufficient
func grow(s []int) {
    s = append(s, 999)  // Might allocate new array
    s[0] = 888  // Might be lost if caller doesn't use return
}
// Fix: Return the slice
func grow(s []int) []int {
    s = append(s, 999)
    s[0] = 888
    return s
}
Enter fullscreen mode Exit fullscreen mode

Slice Capacity Growth (Go 1.18+)

Initial Cap Append Until New Cap
0 1 1
1 2 2
2 3 4
4 5 8
8 9 16
16 17 32
32 33 64
64 65 128
128 129 256
256 257 512
512 513 1024
1024 1025 1280 (~1.25x)

This growth pattern is a compiler implementation detail. Don't rely on it exactly, but understand it exists.

Written while debugging a slice bug at 11pm. Edited twice. Tested on Go 1.22. No AI generated the panic examples — I earned every one of them.

Top comments (0)