DEV Community

Cover image for Go Slices: Why Your Function Isn't Changing What You Think It Is
Rahul Tomer
Rahul Tomer

Posted on

Go Slices: Why Your Function Isn't Changing What You Think It Is

15 min read · Beginner-friendly · Real code you can paste into Go Playground


The bug that confuses every Go beginner

You pass your slice to a function. The function changes some values. You print the slice back in main... and some changes are there, some aren't.

Sound familiar? This article explains exactly why — with diagrams, not walls of text.


Our data — a fleet of vehicles

Two structs, one slice. Everything in this article uses these types.

type VehicleType struct {
    TwoWheeler, ThreeWheeler, FourWheeler bool
}

type Vehicle struct {
    Model, Color string
    Type         VehicleType  // nested by value — not a pointer
}

type Fleet []Vehicle
Enter fullscreen mode Exit fullscreen mode

Fill it with two vehicles:

fleet := Fleet{
    {Model: "Bike", Color: "Red",  Type: VehicleType{TwoWheeler:  true}},
    {Model: "Car",  Color: "Blue", Type: VehicleType{FourWheeler: true}},
}
Enter fullscreen mode Exit fullscreen mode

What is a slice? (the sticky note model)

Before anything else, burn this mental model in — a slice variable is just a 3-field sticky note. The real data lives on the heap.

A slice is just a sticky note pointing to the real data on the heap

When you pass fleet to a function, Go copies the sticky note — not the parking lot.


Pass by value — a photocopy of the sticky note

The function gets its own header copy. Both copies point at the same backing array.

Pass by value — two headers sharing one backing array. Element mutations are visible, append is not.

func byValue(fleet Fleet) {
    fleet[0].Color = "Green"         // ✅ writes to shared heap — caller SEES it
    fleet[0].Type.TwoWheeler = false  // ✅ nested struct is on the heap too
    fleet = append(fleet, Vehicle{})  // ❌ updates ONLY the function's copy of len
}
Enter fullscreen mode Exit fullscreen mode

What the caller sees after byValue(fleet):

Action Visible to caller?
fleet[0].Color → "Green" ✅ Yes
fleet[0].Type.TwoWheeler → false ✅ Yes
len(fleet) → 3 from append ❌ No — still 2

💡 Seems quite easy: Repainting a car already in the lot? Everyone sees it. Driving a new car into YOUR copy of the address? The owner's map still shows 2 cars.


Pass by reference — handing over the key

Pass *Fleet so the function can update the caller's header — including length.

Pass by reference — pointer to the slice header, all changes including append are visible to caller

func byRef(fleet *Fleet) {
    (*fleet)[0].Color = "Yellow"       // ✅ visible to caller
    *fleet = append(*fleet, Vehicle{}) // ✅ len change visible to caller too
}

// call it:
byRef(&fleet)
Enter fullscreen mode Exit fullscreen mode

What the caller sees after byRef(&fleet):

Action Visible to caller?
fleet[0].Color → "Yellow" ✅ Yes
len(fleet) → 3 from append ✅ Yes
New vehicle at fleet[2] ✅ Yes

🔑 As simple as that: You handed the function the actual key to the parking office. It can repaint cars AND add new bays — and you'll see all of it.


Deep clone — two completely separate parking lots

Need zero shared state? Use copy() to build a brand-new backing array.

Deep clone using copy() — two completely independent backing arrays, no shared memory

clone := make(Fleet, len(fleet))
copy(clone, fleet)           // new backing array at a new address
clone[0].Color = "Purple"    // original fleet untouched ✅
Enter fullscreen mode Exit fullscreen mode

🧬 When to use this: Sorting or filtering for display, transforming data for an API response, or any time changes must not affect the source slice.


Quick reference

Action Pass by Value Pass by Reference
fleet[0].Color = ... ✅ Caller sees it ✅ Caller sees it
fleet[0].Type.X = ... ✅ Caller sees it ✅ Caller sees it
append(fleet, vehicle) ❌ NOT visible ✅ Caller sees it

3 rules — commit these to memory

Three rules to remember forever about Go slices

Rule 1 ✅ — Mutate existing element → always visible, value or pointer.

Rule 2 ⚠️append → only visible when you pass *Fleet (pointer).

Rule 3 🧬 — Total isolation → use copy() for a brand-new backing array.


Full runnable example

Paste this into Go Playground and run it:

package main

import "fmt"

type VehicleType struct {
    TwoWheeler, ThreeWheeler, FourWheeler bool
}
type Vehicle struct {
    Model, Color string
    Type         VehicleType
}
type Fleet []Vehicle

func byValue(f Fleet) {
    f[0].Color = "Green"      // ✅ visible to caller
    f = append(f, Vehicle{})  // ❌ NOT visible to caller
}

func byRef(f *Fleet) {
    (*f)[0].Color = "Yellow"   // ✅ visible to caller
    *f = append(*f, Vehicle{}) // ✅ visible to caller
}

func main() {
    fleet := Fleet{
        {Model: "Bike", Color: "Red",  Type: VehicleType{TwoWheeler: true}},
        {Model: "Car",  Color: "Blue", Type: VehicleType{FourWheeler: true}},
    }

    byValue(fleet)
    fmt.Println(fleet[0].Color, len(fleet)) // Green 2

    byRef(&fleet)
    fmt.Println(fleet[0].Color, len(fleet)) // Yellow 3
}
Enter fullscreen mode Exit fullscreen mode

What about the nested VehicleType?

Because VehicleType is embedded by value inside Vehicle (not a pointer), it lives contiguously in memory with the rest of the struct. No special rules — it copies and mutates exactly like any other field.

If it were a pointer (Type *VehicleType), that would be a different story — but that's a topic for another article.


Rahul Tomer

Turning coffee into bugs

My first blog, found this useful? Let me know in comments.


Top comments (0)