JavaRush /Courses /Go SELF /Slice growth and reallocation: append

Slice growth and reallocation: append

Go SELF
Level 11, Lesson 4
Available

1. Getting to know append

Before this, we worked with slices as if they were a “shelf with cells”: there is s[0], s[1], you can iterate with a loop. But at some point, a task of another type inevitably appears: “count numbers until zero,” “collect all words from input,” “read an unknown number of values.” And then an unpleasant thing becomes clear: you don’t know the length of the list in advance.

In languages with “lists,” this most often looks like magic: you add an element — and the list grows by itself. In Go there is no magic, but there is a very clear contract. A slice is not a container “by itself,” but a small description: how many elements currently exist (len) and how much space is reserved for the future (cap). So “write the next element” is not just assignment by index. Sometimes there is enough room, sometimes there isn’t, and then you need a careful “move” to a larger chunk of memory.

The function append is like a command: “add to the end, and if there isn’t enough space — expand the storage and return the updated slice to me.” That is why the first important habit in this lecture sounds almost like a traffic rule: you must always save the result of append. Not because Go is being difficult, but because after adding, your list may change in length, and sometimes even the “address where the elements live.”

If you remember this idea, everything else about append becomes calm and predictable: len is responsible for what “exists”, cap is responsible for the “spare capacity,” and append is responsible for safe growth.

2. The append contract and why you must save the result

Imagine that a slice is not a “rubber list,” but a label on a box: the label says how many elements are currently inside (len) and how much space is still available in the box (cap). When you call append, you ask Go: “add one more element to the end.” Sometimes Go really does put it into the same box. But sometimes the box is too small, and then Go takes a bigger box, moves everything into it, and puts the new element there.

That’s where the main law comes from: append always returns an updated slice. Even if the elements “seem” to stay in the same place, after adding, len definitely changed. And if there was a “move,” then the data pointer changed too. That’s why writing append(s, x) without assignment is almost always a thinking error: it’s as if you asked “add,” got a new label… and threw it in the trash.

The most correct basic template looks like this: “added — saved the result.”

package main

import "fmt"

func main() {
	s := []int{1, 2}
	s = append(s, 3)
	fmt.Println(s) // [1 2 3]
}

Historical fact: before append existed, people had to manually write code that checks cap, allocates a new array, copies the data, and only then adds the element. It was such a frequent pain that append appeared as a built-in function and made life noticeably easier.

The main rule of append: it returns a new slice

append looks like a regular function, but it is a built-in language function. And the key point: the result of append is a slice that you must keep using. Even if it seems to you that “it just added to the end,” in reality it might have:

  1. written into the existing backing array (if there was enough cap);
  2. allocated a new backing array, copied the elements there, and appended into that (if there wasn’t enough cap).

And in both cases append returns an updated slice header. There is solid logic here: if append changed the length, it must give you the new len. If append performed reallocation, it must give you the new data pointer. That is why in Go many functions that change the “shape” of a slice return the slice back (and you can’t ignore it).

From now on, treat this as a reflex: s = append(s, ...).

Note: this is not “Go being pedantic,” it’s literally the function’s contract.

Why you can’t write append(s, x) and hope it “just works”

When you write append(s, x) and don’t save the result, you throw away the information about the new len (and possibly the new memory). From the program’s point of view, the variable s stays the same: it has the same len, the same cap, and the same pointer to the backing array.

An example of “how to pretend you added an element, but actually didn’t”:

package main

import "fmt"

func main() {
	s := []int{1, 2}

	append(s, 3)                 // result ignored
	fmt.Println(s, len(s))       // [1 2] 2
	fmt.Println(append(s, 3))    // [1 2 3]  <-- but this is a temporary value
}

Here, the last line prints a slice that exists, but it is not assigned back into s. It’s like you bought bread, put it into a bag… and left the bag at the checkout.

A slightly more dangerous version is when you ignore the result inside a function:

package main

import "fmt"

func addOne(s []int) {
	append(s, 999) // ignore the result
}

func main() {
	s := []int{1, 2}
	addOne(s)

	fmt.Println(s) // [1 2]
}

The slice in the parameter s is a copy of the slice header (a copy of the description). If inside the function you didn’t return the new slice to the outside, the calling code has no way to learn that the length should have changed.

The correct version is to return the slice:

package main

import "fmt"

func addOne(s []int) []int {
	s = append(s, 999)
	return s
}

func main() {
	s := []int{1, 2}
	s = addOne(s)

	fmt.Println(s) // [1 2 999]
}

3. Slice growth and reallocation

len always grows, cap sometimes does (and in jumps)

When you add elements, len grows in a strictly predictable way: add one — length +1. Add three — length +3. But cap is trickier: Go tries to allocate memory so that future append calls are cheaper, so capacity often grows in “spurts.” Exactly how is an implementation detail; you can’t base program logic on it. But it is useful to observe it so your brain stops expecting magic.

Let’s write a tiny “growth sensor”:

package main

import "fmt"

func main() {
	s := make([]int, 0) // for now just an empty slice

	for i := 1; i <= 8; i++ {
		s = append(s, i)
		fmt.Printf("i=%d len=%d cap=%d s=%v\n", i, len(s), cap(s), s)
	}
}

The output will look roughly like this (the exact cap values may differ, and that’s normal):

i=1 len=1 cap=1 s=[1]
i=2 len=2 cap=2 s=[1 2]
i=3 len=3 cap=4 s=[1 2 3]
...

Two ideas matter here. First: len is “how many elements actually exist,” and it’s what indexing is based on. Second: cap is “how much space is allocated,” and it can change when Go decides it’s time to grow.

Backing array reallocation: what happens during a “move”

The word “reallocation” sounds like something from accounting, but the meaning is simple: if there isn’t enough space, Go allocates a new, larger array and copies your elements there. The analogy is as everyday as it gets: you live in an apartment (the backing array). While you don’t have many things, it’s fine. When you have more things than closets (cap), you move to a bigger apartment. The old address still exists, but you’re no longer there.

Inside append, the logic is roughly like this (simplified: not as a spec, but as a mental model):

flowchart TD
    A["There is a slice s (len, cap)"] --> B{"Is cap enough to add?"}
    B -->|yes| C["Write new elements into the same backing array, len increases"]
    B -->|no| D["Allocate a new, larger backing array"]
    D --> E["Copy old elements"]
    E --> F["Add new elements"]
    C --> G["Return the new slice header"]
    F --> G

Now an important consequence: if you have another slice somewhere that referenced the old backing array, after reallocation it can become a “handle to the old place.”

Let’s show it with an example. We will intentionally create a slice with a small cap to reliably force Go to grow:

package main

import "fmt"

func main() {
	s := make([]int, 0, 1) // len=0, cap=1

	s = append(s, 1)
	t := s // t looks at the same place as s

	fmt.Printf("before: s=%v t=%v\n", s, t) // before: s=[1] t=[1]

	s = append(s, 2) // cap was 1, now we need 2 => there will be reallocation
	t[0] = 99        // modify the "old place"

	fmt.Printf("after:  s=%v t=%v\n", s, t) // after:  s=[1 2] t=[99]
}

The point of the demo is: after expansion, s could have moved to a new backing array, while t stayed pointing to the old one. So changing t[0] does not have to be reflected in s[0].

This is not a “Go bug,” it’s a consequence of the slice being a small descriptor structure. We already discussed that the assignment t := s copies the slice header. After reallocation, these two headers start pointing to different arrays.

append and a nil slice: a perfectly normal pair

Sometimes a beginner thinks: “If a slice is nil, then you can’t append to it.” In practice, in Go a nil slice is a valid initial state for accumulating data. append can start from nil: it will just allocate a backing array by itself.

package main

import "fmt"

func main() {
	var s []int // nil

	s = append(s, 10)
	s = append(s, 20)

	fmt.Println(s, s == nil) // [10 20] false
}

This is a very convenient style: “while there are no elements — it’s just nil, and then we grow.” Plus, range over a nil slice is safe, so you don’t have to initialize something immediately “just to feel safe.”

Micro-diagnostics: the address of the first element

Sometimes, to believe in a “move,” you want to see it with your own eyes. We can carefully print the address of the first element via "%p". Important: taking &s[0] is only allowed when len(s) > 0, otherwise there will be a panic (because the element doesn’t exist).

package main

import "fmt"

func main() {
	s := make([]int, 0, 1)

	s = append(s, 1)
	fmt.Printf("len=%d cap=%d addr=%p\n", len(s), cap(s), &s[0]) // addr=0x...

	s = append(s, 2) // reallocation is almost guaranteed
	fmt.Printf("len=%d cap=%d addr=%p\n", len(s), cap(s), &s[0]) // addr=0x... (most likely different)
}

If the address changed, the backing array definitely moved. If in some environment the address happens to match (rare, but theoretically you can run into surprises due to optimizations/allocator behavior), don’t turn that into a “scientific conclusion”: application logic must not depend on addresses. We care about the model and the contract of append, not mystical “where exactly in memory the array lives.”

4. Practice: a mini app with a task list

Up to this point in the course, we worked a lot with individual variables and strings. Now we get a new superpower: storing many values of the same type. Let’s start building the simplest task list (for now without files, without structs, without statuses — just strings in a slice). This app is intentionally simple, but it will solidly reinforce the idea that “append returns an updated slice.”

First, let’s create “memory” for tasks and add a couple:

package main

import "fmt"

func main() {
	var tasks []string // nil slice, a normal start

	tasks = append(tasks, "Buy milk")
	tasks = append(tasks, "Learn append (and don't forget to save the result)")

	fmt.Println(tasks) // [Buy milk Learn append (and don't forget to save the result)]
}

Now let’s add a function that adds a task correctly. Notice: the function returns the new tasks.

package main

import "fmt"

func addTask(tasks []string, title string) []string {
	tasks = append(tasks, title)
	return tasks
}

func main() {
	var tasks []string

	tasks = addTask(tasks, "Do some exercise")
	tasks = addTask(tasks, "Pet the cat (the cat won't pet itself)")

	fmt.Println(tasks) // [Do some exercise Pet the cat (the cat won't pet itself)]
}

And now we will intentionally show a typical mistake: a function that “does something,” but doesn’t return the result to the outside:

package main

import "fmt"

func addTaskWrong(tasks []string, title string) {
	append(tasks, title) // threw away the result
}

func main() {
	var tasks []string

	addTaskWrong(tasks, "This task will be lost")
	fmt.Println(tasks) // []
}

This kind of mistake is especially tricky because the code compiles and looks logical, but the application behaves as if it “sometimes forgets.”

5. Common mistakes when working with append

Mistake #1: ignoring the return value of append.
The most frequent problem: writing append(s, x) and expecting s to “update itself.” In Go, append returns a new slice because len, cap, and even the pointer to the backing array could change. The habit “always assign the result” is not style, it’s correctness.

Mistake #2: accidentally using := instead of = and “losing” the updated slice.
When the variable already exists, but inside a block you write s := append(s, x), you may create a new variable s that lives only in that block. Outside, the old one remains. It looks almost identical, but the effect is like a trick: “added, but nothing got added.”

Mistake #3: thinking that two slices after t := s will forever share the same elements.
Until the first serious growth, this is often true, so the bug shows up “not always.” But upon reallocation, one slice may move, while the other stays on the old backing array. As a result, changes through one stop being visible in the other — and that is normal behavior.

Mistake #4: trying to index “up to cap” instead of “up to len”.
Sometimes a person sees cap=10 and thinks: “So I have 10 cells.” There is space, but logically there are no elements: indexing is allowed only in the range 0…len-1. If len=0, then s[0] is a panic, even if cap is huge.

Mistake #5: mixing the “fill by indices” style and the “grow via append” style without understanding the length.
For example, creating make([]int, 5) (that is, len=5, already five zeros), and then also calling append in a loop. As a result, you get “five zeros, and then useful elements” — and you’ll spend a long time wondering who slipped those zeros in. The problem is not append, but that you started with the wrong len.

1
Task
Go SELF, level 11, lesson 4
Locked
Sensor data stream
Sensor data stream
1
Task
Go SELF, level 11, lesson 4
Locked
Batch update
Batch update
1
Task
Go SELF, level 11, lesson 4
Locked
Lost append
Lost append
1
Task
Go SELF, level 11, lesson 4
Locked
Diverged slices
Diverged slices
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION