Slices Are Not Arrays (And Neither Is List<T>)

Every C# developer coming to Go makes the same mistake: they see []int and think “array” or “list.” It’s neither. It’s a slice, and it has semantics that will bite you if you don’t understand them.

This is one of those Go concepts that seems simple until it isn’t. Let’s dig in.

What a Slice Actually Is

A slice is a struct with three fields:

// Conceptually:
type slice struct {
    array *T   // pointer to underlying array
    len   int  // number of elements in use
    cap   int  // total capacity of underlying array
}

When you create a slice, you’re creating this header. The actual data lives in a backing array somewhere.

s := []int{1, 2, 3, 4, 5}
// s.array -> [1, 2, 3, 4, 5, _, _, _]  (backing array, might have extra capacity)
// s.len = 5
// s.cap = 5 (or more)

This is fundamentally different from both C# arrays and List<T>.

The C# Mental Model (And Why It’s Wrong Here)

C# arrays are fixed-size, directly hold their data:

int[] arr = new int[5];
// arr IS the array, fixed size, done

C# List wraps an array with growth semantics:

var list = new List<int> { 1, 2, 3 };
list.Add(4);  // might reallocate internally
// But list is a reference type - you always work with the same list object

Go slices are value types that point to arrays:

s := []int{1, 2, 3}
s = append(s, 4)  // might return a NEW slice header pointing to NEW array

That last line is where people get burned. append might modify the backing array in place, or it might allocate a new array and return a slice pointing to it. You must use the return value.

The append Trap

This looks fine:

func addItem(items []int, item int) {
    items = append(items, item)
}

func main() {
    s := []int{1, 2, 3}
    addItem(s, 4)
    fmt.Println(s)  // [1 2 3] - item wasn't added!
}

The slice header is passed by value. append creates a new header (possibly pointing to a new array), but that new header only exists inside addItem. The caller’s s is unchanged.

Fixes:

// Option 1: Return the new slice
func addItem(items []int, item int) []int {
    return append(items, item)
}

s = addItem(s, 4)  // use the return value

// Option 2: Use a pointer to slice (less common)
func addItem(items *[]int, item int) {
    *items = append(*items, item)
}

addItem(&s, 4)

In C#, this wouldn’t be a problem—List<T> is a reference type:

void AddItem(List<int> items, int item) {
    items.Add(item);  // modifies the actual list
}

Go’s slice is not a reference type. It’s a value type that contains a pointer. Subtle but critical difference.

Shared Backing Arrays (The Real Gotcha)

Here’s where it gets properly dangerous:

original := []int{1, 2, 3, 4, 5}
slice1 := original[1:3]  // [2, 3]
slice2 := original[2:4]  // [3, 4]

slice1[1] = 999  // modify slice1

fmt.Println(original)  // [1 2 999 4 5]
fmt.Println(slice1)    // [2 999]
fmt.Println(slice2)    // [999 4]

All three slices share the same backing array. Modify one, you modify them all.

This is nothing like C#:

var original = new int[] { 1, 2, 3, 4, 5 };
var slice = original[1..3];  // creates a NEW array in C# 8+
slice[1] = 999;
Console.WriteLine(string.Join(", ", original));  // 1, 2, 3, 4, 5 - unchanged

C# ranges create copies. Go slices share memory. Neither is wrong, but they’re completely different behaviours.

The Capacity Gotcha

It gets worse. When you slice, you might have hidden capacity:

original := []int{1, 2, 3, 4, 5}
slice := original[1:3]  // [2, 3], but capacity is 4!

fmt.Println(len(slice))  // 2
fmt.Println(cap(slice))  // 4 - can see elements 1-4 of original

slice = append(slice, 999)  // doesn't allocate new array - overwrites original!

fmt.Println(original)  // [1 2 3 999 5] - element 3 (index 3) was overwritten

The append saw capacity, used it, and stomped on data that logically wasn’t part of slice but physically was in the backing array.

To prevent this:

// Limit capacity when slicing
slice := original[1:3:3]  // [low:high:max] - capacity is high-low = 2

// Or copy explicitly
slice := make([]int, 2)
copy(slice, original[1:3])

Length vs Capacity

This distinction doesn’t exist in List<T> (internally it does, but you don’t see it):

s := make([]int, 3, 10)  // length 3, capacity 10
fmt.Println(len(s))  // 3
fmt.Println(cap(s))  // 10

s[0] = 1  // OK
s[5] = 1  // panic: index out of range - len is 3!

s = s[:cap(s)]  // extend slice to full capacity
s[5] = 1        // now OK

Length is how many elements are accessible. Capacity is how many could be before reallocation. This is an implementation detail that Go exposes and expects you to understand.

nil vs Empty Slice

Oh good, more nil pain:

var s1 []int          // nil slice
s2 := []int{}         // empty slice
s3 := make([]int, 0)  // empty slice

fmt.Println(s1 == nil)  // true
fmt.Println(s2 == nil)  // false
fmt.Println(s3 == nil)  // false

// But they all behave the same for most operations
fmt.Println(len(s1), len(s2), len(s3))  // 0 0 0

A nil slice and an empty slice are functionally equivalent for len, cap, append, and range. But they’re not equal to each other, and some JSON encoders treat them differently.

json.Marshal(s1)  // might produce "null"
json.Marshal(s2)  // produces "[]"

Wonderful.

Patterns That Help

Always Capture append’s Return

s = append(s, item)  // always assign back

Copy When Returning Subslices

func getSubset(data []int) []int {
    subset := data[10:20]
    
    // Bad: caller might have data, mutating subset affects data
    return subset
    
    // Good: caller gets independent slice
    result := make([]int, len(subset))
    copy(result, subset)
    return result
}

Preallocate When Size Is Known

// Might reallocate multiple times
results := []int{}
for _, item := range items {
    results = append(results, transform(item))
}

// Single allocation
results := make([]int, 0, len(items))
for _, item := range items {
    results = append(results, transform(item))
}

// Or even better, known length
results := make([]int, len(items))
for i, item := range items {
    results[i] = transform(item)
}

Use the Three-Index Slice for Safety

// Dangerous: slice might have hidden capacity
sub := data[a:b]

// Safe: capacity is limited
sub := data[a:b:b]

The Comparison

AspectC# ArrayC# ListGo Slice
Fixed sizeYesNoNo
Reference typeYesYesNo (value type with pointer)
Slicing copiesYes (ranges)N/ANo (shares memory)
append semanticsN/AAdd modifies listReturns new header
Hidden capacityNoYes (internal)Yes (exposed)
nil vs emptynull vs new int[0]null vs new ListDifferent behaviours

The Honest Take

Slices are Go’s most footgun-laden feature. They look simple, they have subtle semantics, and the gotchas aren’t obvious until you’ve been bitten.

What Go gets right:

  • Efficient memory sharing when you want it
  • Clear distinction between length and capacity
  • append is convenient (when used correctly)
  • No boxing, good cache locality

What C# does better:

  • List<T> is a reference type—modifications are visible everywhere
  • Range slicing copies by default—no surprise aliasing
  • Span<T> gives you explicit no-copy semantics when you want them
  • You don’t have to remember the three-index slice syntax

The verdict: Slices are powerful and efficient. They’re also a sharp tool that will cut you. Every Go developer has been bitten by shared backing arrays or forgotten append returns.

My advice: be paranoid about slice aliasing. Copy when returning subslices. Always use the return value of append. And when something mutates unexpectedly, check your slices first.


That wraps up the memory section. Next we’ll move into concurrency—goroutines, channels, and why Go’s approach is fundamentally different from async/await.