Free Ebook cover Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

Go (Golang) Fundamentals: Simple, Fast Programs for Beginners

New course

12 pages

Slices and Arrays in Go: Working with Collections Safely and Efficiently

Capítulo 5

Estimated reading time: 2 minutes

+ Exercise

1) Arrays: fixed size, value semantics, and where you’ll see them

An array in Go has a fixed length that is part of its type. That means [3]int and [4]int are different types. Arrays are useful when the size is known at compile time and should never change.

Fixed size is part of the type

var a [3]int        // exactly 3 ints, initially all 0s
b := [3]int{1, 2, 3}
// c := [4]int{1, 2, 3, 4} // different type than [3]int

Value semantics (arrays are copied on assignment)

Arrays behave like values: assigning or passing an array copies all elements. This is a key mental model difference from slices.

a := [3]int{1, 2, 3}
b := a      // copies all 3 elements
b[0] = 99
// a is still [1 2 3], b is [99 2 3]

When arrays appear in APIs

In everyday Go code, you’ll more often use slices, but arrays show up in APIs when the size is meaningful and fixed, for example:

  • Cryptographic hashes: [32]byte (SHA-256), [16]byte (UUID-like fixed sizes)
  • Low-level protocols or binary formats with fixed-length fields
  • Performance-sensitive code where fixed size enables optimizations
// A function that requires exactly 32 bytes (e.g., a SHA-256 digest)
func useDigest(d [32]byte) {
    // ...
}

If you want to avoid copying large arrays, you’ll often pass a pointer to the array instead.

func mutate(a *[3]int) {
    (*a)[0] = 42
}

2) Slices: length vs capacity, underlying array, slicing, and append reallocation

A slice is a lightweight descriptor that refers to a contiguous segment of an underlying array. The slice itself is not the data; it points to the data.

Continue in our app.

You can listen to the audiobook with the screen off, receive a free certificate for this course, and also have access to 5,000 other free online courses.

Or continue reading below...
Download App

Download the app

Length vs capacity

len(s) is how many elements are currently in the slice. cap(s) is how many elements you can grow to (via append) before needing a new underlying array.

s := []int{10, 20, 30, 40}
// len(s) == 4
// cap(s) >= 4 (often 4 for a literal, but not guaranteed in general)

The underlying array concept (sharing)

Multiple slices can refer to the same underlying array. Mutating through one slice can affect the other if they overlap.

base := []int{1, 2, 3, 4, 5}
a := base[1:4] // elements: 2,3,4
b := base[2:5] // elements: 3,4,5

a[1] = 99
// base is now [1 2 99 4 5]
// b sees the change because it overlaps the same underlying array

Slicing expressions

The common form is s[low:high] where low is inclusive and high is exclusive. You can omit bounds: s[:high], s[low:], s[:].

s := []string{"a", "b", "c", "d"}
left := s[:2]   // "a", "b"
mid := s[1:3]   // "b", "c"
all := s[:]    // full slice (still shares underlying array)

There is also a full slice expression s[low:high:max] that lets you limit capacity to control future append behavior.

base := []int{1, 2, 3, 4, 5}
sub := base[1:3:3] // len=2 (2,3), cap=2 (capacity limited)
sub = append(sub, 99)
// Because cap was limited, append must allocate a new array.
// base is unchanged by this append.

How append can reallocate

append adds elements to a slice. If the slice has enough capacity, it reuses the same underlying array. If not, it allocates a new array, copies elements, and returns a new slice header pointing to the new array. Always assign the result of append.

s := make([]int, 0, 2)
s = append(s, 1, 2) // fits in cap=2
s2 := append(s, 3)  // exceeds cap, likely reallocates

// s2 may now point to a different underlying array than s.

A common bug is appending to a slice inside a function but forgetting to return it (or forgetting to assign the returned slice). The caller won’t see growth if reallocation happened.

3) Creating slices: literals, make, and nil vs empty (comparisons and JSON)

Slice literals

Use a literal when you know the initial elements.

nums := []int{1, 2, 3}
words := []string{"go", "is", "fast"}

make for length and capacity

make([]T, len, cap) allocates an underlying array and returns a slice. If you omit cap, it equals len.

a := make([]int, 5)      // len=5, cap=5, elements are 0
b := make([]int, 0, 10)  // len=0, cap=10, ready to append efficiently

Step-by-step mental model for make([]int, 0, 10):

  • Allocate an array of 10 ints (all zeros).
  • Create a slice header pointing to that array.
  • Set length to 0 (no elements “in use” yet).
  • Set capacity to 10 (room to grow without reallocating).

nil slice vs empty slice

A nil slice has no underlying array. An empty slice has length 0 but is non-nil (it may or may not have an allocated array depending on how it was created).

var sNil []int          // nil slice: sNil == nil is true
sEmpty := []int{}       // empty slice: len==0, but sEmpty != nil
sMake := make([]int, 0) // empty slice: len==0, sMake != nil

Important behaviors:

  • Comparison: slices can only be compared to nil. You cannot do s1 == s2 for slices.
  • len/cap: len(nilSlice) == 0 and cap(nilSlice) == 0.
  • append: appending to a nil slice works; it allocates as needed.
var s []int
s = append(s, 1, 2, 3) // OK

JSON encoding differences (nil vs empty)

When encoding to JSON, a nil slice typically becomes null, while an empty slice becomes []. This matters for APIs where clients distinguish “missing” from “present but empty”.

// With encoding/json:
// var a []int        -> "a": null
// b := []int{}       -> "b": []

If you want JSON arrays to always be [], ensure you initialize slices to empty rather than leaving them nil.

4) Common operations: append patterns, copy, deleting, and avoiding accidental sharing

Append patterns

Appending one slice to another uses ... to expand elements.

a := []int{1, 2}
b := []int{3, 4}
a = append(a, b...) // a is now [1 2 3 4]

Building a slice in a loop should usually assign the result each time.

out := make([]int, 0, 5)
for i := 0; i < 5; i++ {
    out = append(out, i*i)
}

copy for controlled duplication

copy(dst, src) copies up to min(len(dst), len(src)) elements and returns the number copied.

src := []int{1, 2, 3}
dst := make([]int, len(src))
copy(dst, src)
// dst is an independent copy

To copy a sub-slice into a new backing array:

base := []int{10, 20, 30, 40}
sub := base[1:3] // [20 30], shares base
independent := make([]int, len(sub))
copy(independent, sub)

Deleting elements by index

To delete element at index i while preserving order, use append to stitch the parts together.

func deleteAtPreserveOrder(s []int, i int) []int {
    return append(s[:i], s[i+1:]...)
}

Step-by-step mental model:

  • s[:i] is everything before the element.
  • s[i+1:] is everything after the element.
  • append concatenates them, overwriting the removed element’s slot.

If order does not matter, you can delete in O(1) by swapping with the last element, then shortening the slice.

func deleteAtNoOrder(s []int, i int) []int {
    s[i] = s[len(s)-1]
    return s[:len(s)-1]
}

Avoiding accidental sharing

Because slices can share an underlying array, returning a sub-slice can unintentionally keep a large array alive in memory or allow later mutations to leak across boundaries.

When you need isolation, copy into a new slice.

// Returns an independent copy of the first n elements.
func prefixCopy(s []int, n int) []int {
    if n > len(s) {
        n = len(s)
    }
    out := make([]int, n)
    copy(out, s[:n])
    return out
}

When you want to prevent an append from modifying the original backing array, consider limiting capacity with the full slice expression before appending.

func safeAppend(base []int, extra int) []int {
    view := base[:len(base):len(base)] // cap == len, forces reallocation on append
    return append(view, extra)
}

5) Performance-aware patterns: preallocating and when it matters

Appending repeatedly can cause multiple reallocations and copies as the slice grows. Go grows capacity automatically, but you can reduce allocations by preallocating when you have a good estimate of final size.

Preallocate with make([]T, 0, n)

// Suppose you expect about n results.
out := make([]int, 0, n)
for _, v := range input {
    if v%2 == 0 {
        out = append(out, v)
    }
}

This pattern matters most when:

  • You are building large slices in hot loops.
  • You can estimate the number of elements reasonably well.
  • You want to reduce GC pressure by avoiding intermediate arrays.

It matters less when slices are small or the code is not performance-critical. Prefer clarity first, then optimize based on profiling.

Mini-lab 1: Filter a slice

Goal: implement a function that returns a new slice containing only the elements that match a condition.

Task

Write FilterInts that keeps only values for which keep(v) returns true.

func FilterInts(in []int, keep func(int) bool) []int {
    // TODO
}

Step-by-step approach

  • Create an output slice with length 0.
  • Optionally preallocate capacity (often len(in) is a safe upper bound).
  • Loop over input; append values that pass the predicate.
  • Return the output slice.

One good implementation

func FilterInts(in []int, keep func(int) bool) []int {
    out := make([]int, 0, len(in))
    for _, v := range in {
        if keep(v) {
            out = append(out, v)
        }
    }
    return out
}

Try it with different predicates:

evens := FilterInts([]int{1, 2, 3, 4, 5, 6}, func(v int) bool {
    return v%2 == 0
})

big := FilterInts([]int{3, 10, 7, 25}, func(v int) bool {
    return v >= 10
})

Mini-lab 2: Safely remove an element by index

Goal: implement a function that removes the element at index i without panicking, and returns the updated slice plus a boolean indicating success.

Task

func RemoveAt(in []string, i int) ([]string, bool) {
    // TODO
}

Step-by-step approach

  • Validate i: it must be between 0 and len(in)-1.
  • If invalid, return the original slice and false.
  • If valid, create the result by joining in[:i] and in[i+1:].
  • Return the new slice and true.

Implementation (preserves order)

func RemoveAt(in []string, i int) ([]string, bool) {
    if i < 0 || i >= len(in) {
        return in, false
    }
    out := append(in[:i], in[i+1:]...)
    return out, true
}

If you need to ensure the removed element’s storage doesn’t keep references alive (important for slices of pointers/strings in long-lived structures), you can clear the slot before reslicing.

func RemoveAtClear(in []string, i int) ([]string, bool) {
    if i < 0 || i >= len(in) {
        return in, false
    }
    copy(in[i:], in[i+1:])
    in[len(in)-1] = "" // clear reference
    return in[:len(in)-1], true
}

Now answer the exercise about the content:

In Go, why should you usually assign the result of append back to the slice variable (e.g., s = append(s, v))?

You are right! Congratulations, now go to the next page

You missed! Try again.

append can reuse capacity or allocate a new underlying array if capacity is exceeded. It returns a (possibly) new slice header, so you must assign the result to keep the updated slice.

Next chapter

Maps in Go: Key-Value Data, Lookups, and Idiomatic Patterns

Arrow Right Icon
Download the app to earn free Certification and listen to the courses in the background, even with the screen off.
  • Read this course in the app to earn your Digital Certificate!
  • Listen to this course in the app without having to turn on your cell phone screen;
  • Get 100% free access to more than 4000 online courses, ebooks and audiobooks;
  • + Hundreds of exercises + Educational Stories.