Cont

Go Slices: Common pitfalls, hidden behaviors, and how to avoid bugs

Go Slices: Common pitfalls, hidden behaviors, and how to avoid bugs


Introduction

Slices are one of Go’s most used data structures, and also one of the most misunderstood at an advanced level. They look simple on the surface, but subtle behaviors around memory sharing, capacity growth, and mutation can quietly introduce bugs in production systems.

This tutorial focuses on real caveats, common mistakes, and practical patterns around Go slices. The goal is not to explain what a slice is—that’s assumed—but to explain how slices behave under pressure: in data pipelines, backend services, and performance-sensitive systems.


Background

A Go slice is not an array. It is a descriptor that contains three things:

  • a pointer to an underlying array
  • a length
  • a capacity

The key mental model is this:

Multiple slices can point to the same underlying array.

This single fact explains most slice-related bugs. Appending, slicing, or passing slices between functions can unexpectedly mutate data elsewhere if you are not careful about ownership and capacity.


Scenario

Imagine a financial risk engine that processes time-ordered price ticks. Each stage of the pipeline receives a slice of prices, applies transformations, and forwards the result.

One stage trims outliers. Another aggregates values. A third persists results.

If one stage accidentally mutates data owned by another stage, you don’t get a crash. You get silent financial miscalculations.

That’s far worse.


The Problem

Create a new file:

touch slice_bug.go


Run it with:

go run slice_bug.go


package main

import "fmt"

func removeOutliers(prices []float64) []float64 {
    // Remove first and last value (simple outlier trimming)
    return prices[1 : len(prices)-1]
}

func main() {
    rawPrices := []float64{100.0, 101.5, 102.0, 500.0, 103.0}

    trimmed := removeOutliers(rawPrices)

    trimmed[0] = 999.0

    fmt.Println("Raw prices:", rawPrices)
    fmt.Println("Trimmed prices:", trimmed)
}



Code Execution

When the program runs, the output is surprising:

Raw prices: [100 999 102 500 103]
Trimmed prices: [999 102 500]


The removeOutliers function appears to return a new slice. In reality, it returns a view into the same underlying array.

Mutating trimmed mutated rawPrices.

In a real system, this kind of bug can propagate incorrect values across unrelated pipeline stages.


Understanding Slice Aliasing

The bug does not come from using slice expressions, it comes from assuming that slicing creates independent data.

prices[1:len(prices)-1] creates a new slice header, but it still points to the same underlying array as the original slice.

That means:

  • Both slices see the same memory
  • Mutating elements through one slice mutates the other
  • This happens silently, with no compiler warning

So the real issue is aliasing: multiple slices referencing the same backing array without clear ownership rules.


Explicit Copy to Break Memory Sharing

package main

import "fmt"

func removeOutliers(prices []float64) []float64 {
    result := make([]float64, len(prices)-2)
    copy(result, prices[1:len(prices)-1])
    return result
}

func main() {
    rawPrices := []float64{100.0, 101.5, 102.0, 500.0, 103.0}

    trimmed := removeOutliers(rawPrices)
    trimmed[0] = 999.0

    fmt.Println("Raw prices:", rawPrices)
    fmt.Println("Trimmed prices:", trimmed)
}


  • make allocates a new backing array
  • copy copies only the needed values
  • The returned slice owns its memory

Why this works well

Ownership is explicit. Each pipeline stage can safely mutate its data.

What about alternatives?

  • Returning subslices assumes callers understand memory aliasing
  • Documenting “do not mutate” is not enforcement
  • Defensive copying at call sites scatters responsibility

Capacity Traps When Appending

A more subtle bug appears when appending.

package main

import "fmt"

func enrichPrices(prices []float64) []float64 {
    return append(prices, 104.0)
}

func main() {
    base := []float64{100, 101, 102}
    view := base[:2]

    enriched := enrichPrices(view)

    fmt.Println("Base:", base)
    fmt.Println("View:", view)
    fmt.Println("Enriched:", enriched)
}


Depending on capacity, append may:

  • allocate a new array (safe)
  • reuse the existing array (dangerous)

This makes behavior data-dependent, which is unacceptable in critical systems.

Why this is risky

The caller cannot predict whether append mutates shared memory.


Capacity Limiting to Enforce Safety

package main

import "fmt"

func enrichPrices(prices []float64) []float64 {
    safe := prices[:len(prices):len(prices)]
    return append(safe, 104.0)
}

func main() {
    base := []float64{100, 101, 102}
    view := base[:2]

    enriched := enrichPrices(view)

    fmt.Println("Base:", base)
    fmt.Println("View:", view)
    fmt.Println("Enriched:", enriched)
}


  • The full slice expression sets capacity = length
  • append is forced to allocate new memory

Why this is better

It guarantees isolation without copying upfront.

What about other approaches?

  • Blind copying wastes memory when append is rare
  • Relying on current capacity is brittle and unsafe

Execution remains the same location and command.


Avoiding Hidden Retention (Memory Leaks)

Slices can accidentally keep large arrays alive.

package main

import "fmt"

func extractHeader(buffer []byte) []byte {
    return buffer[:16]
}

func main() {
    hugeBuffer := make([]byte, 10_000_000)
    header := extractHeader(hugeBuffer)

    fmt.Println(len(header))
}


Problem

header keeps a reference to the entire 10MB buffer.

Correct approach

package main

import "fmt"

func extractHeader(buffer []byte) []byte {
    header := make([]byte, 16)
    copy(header, buffer[:16])
    return header
}

func main() {
    hugeBuffer := make([]byte, 10_000_000)
    header := extractHeader(hugeBuffer)

    fmt.Println(len(header))
}


Why this is important

In servers, this mistake leads to unexplained memory growth under load.


Summary

Slices are powerful but dangerous when their memory behavior is misunderstood.

Key takeaways:

  • Slicing does not copy data
  • Appending can mutate shared memory
  • Capacity controls safety
  • Small subslices can retain huge buffers
  • Ownership must be explicit in real systems

Understanding these rules is essential for writing correct, predictable Go code at scale.


Trebuie să fii autentificat pentru a accesa laboratorul cloud și a experimenta cu codul prezentat în acest tutorial.

Autentifică-te