1. Introduction
If you call append on a normal slice, things often look logical: an element gets added to the end—nice. But as soon as you introduce a sub-slice that points into the middle of a shared array, append can suddenly start writing “into the tail” of that array, overwriting elements you thought belonged to something else. It usually looks like mysterious memory corruption, even though it’s really just using spare capacity.
Imagine you have a small learning app: an in-memory task list. No files, no structs yet—just []string.
package main
import "fmt"
func main() {
tasks := []string{"learn Go", "drink water", "sleep"}
fmt.Println(tasks) // [learn Go drink water sleep]
}
Now suppose you want to show the user “the first two tasks”—that is, make a view over the beginning:
package main
import "fmt"
func main() {
tasks := []string{"learn Go", "drink water", "sleep", "repeat"}
first := tasks[:2]
fmt.Println(first) // [learn Go drink water]
}
And now somewhere inside your logic you decide to append a hint to first (for example, for a UI or a report), and you write:
first = append(first, "TIP: don't panic")
And here’s the moment when you might suddenly see that the third element in tasks changed. It’s not because Go is “mean”. It’s because first could have had cap greater than len, and append neatly used free space in the same backing array.
By the way, the whole idea of append as “smart adding” exists specifically so programmers don’t have to manually grow arrays and do copy on every growth step. Go deliberately gives us append to reduce typical code complexity. But convenience has a cost: you need to understand what’s happening with cap.
2. len and cap in a sub-slice: a quick refresher
Before the three-index slice, it helps to “feel with your hands” the mechanics one more time:
- len is how many elements you can see,
- cap is how many elements you could potentially see if you expand the window (reslice/append) without moving to a new array.
And the key point: for a sub-slice, cap usually extends to the end of the backing array, not to the end of len.
Let’s make a diagnostic example. It’s small, but very telling:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d"}
first := tasks[:2]
fmt.Println(len(first), cap(first)) // 2 4
}
Why is cap(first) equal to 4? Because first starts at index 0 of the same backing array, and there’s still room until the end of the array: you can potentially expand the window up to 4 elements.
Now a sub-slice from the middle:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d", "e"}
mid := tasks[1:3] // we see b,c
fmt.Println(mid) // [b c]
fmt.Println(len(mid), cap(mid)) // 2 4 (usually)
}
len(mid) = 2, and cap(mid) will usually be cap(tasks) - 1, because the window starts at index 1 and can still “grow to the right” quite far.
And now the main takeaway we need today: if a sub-slice has extra capacity (cap > len), then append can append into the same backing array and thereby change “someone else’s” elements of the original slice. This is a direct consequence of the slice header model (pointer+len+cap).
3. The append problem in a sub-slice: why neighboring data breaks
Now we’ll deliberately cause a small “disaster” so we can then prevent it cleanly. Important: we’re not doing anything forbidden. We’re doing something that looks reasonable, and then we’re surprised. That’s about 80% of adult programming (the other 20% is putting fmt.Printf in unexpected places).
package main
import "fmt"
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
first := tasks[:2] // [learn water], but cap may be 4
first = append(first, "tip") // append to the "first list"
fmt.Println(first) // [learn water tip]
fmt.Println(tasks) // [learn water tip repeat] ← surprise
}
What happened? first is a window onto the backing array of tasks. first had spare capacity, and append decided: “Why allocate a new array if there’s still room in the old one? I’ll just write the element into the tail.” And it wrote "tip" where "sleep" used to be.
This behavior is not an accident and not “an optimization that sometimes kicks in”. It’s the core model of append: if there is enough cap, grow in place; if not, allocate a new array and copy. That’s why in Go they keep repeating “always store the result of append”: append can return a slice pointing either to the old array or to a new one.
We need protection: we want to be able to say “here’s a sub-slice, but it may not grow to the right”. This is where the hero of the lecture shows up: the full (three-index) slice expression.
4. Full slice expression s[a:b:c]: syntax and meaning
A three-index slice looks scary: s[a:b:c]. But its meaning is very engineering-focused and straightforward: it sets not only len, but also limits the resulting cap.
Formally, for t := s[a:b:c], two formulas hold:
| Expression | Result |
|---|---|
|
|
|
|
And there is a boundary validity rule:
0 <= a <= b <= c <= cap(s)
Notice: the upper bound c is compared specifically against cap(s), not len(s). That’s logical: we’re controlling capacity, and capacity is about the backing array.
Why am I emphasizing that it’s “official”? Because this syntax is part of the language and the slice-expression spec. It’s not a “hack for the chosen few”; it’s a built-in mechanism: “I consciously set visibility bounds and growth bounds.”
5. The key technique: make cap == len so append doesn’t clobber neighbors
Now for the most practical part: most of the time you don’t need an arbitrary c. You need a simple, powerful pattern—limit cap exactly to len so that any append is forced to allocate a new backing array.
For that, these forms are used:
- s[:n:n] — take the first n elements and forbid growth to the right;
- the general form s[a:b:b] — “cap ends exactly where len ends.”
First, let’s see the difference in cap:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d"}
view1 := tasks[:2] // len=2, cap=4
view2 := tasks[:2:2] // len=2, cap=2
fmt.Println(len(view1), cap(view1)) // 2 4
fmt.Println(len(view2), cap(view2)) // 2 2
}
And now—the main effect. First, the “dangerous” variant:
package main
import "fmt"
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
first := tasks[:2]
first = append(first, "tip")
fmt.Println(tasks) // [learn water tip repeat]
}
Now the “protected” variant:
package main
import "fmt"
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
first := tasks[:2:2] // cap is limited
first = append(first, "tip")
fmt.Println(tasks) // [learn water sleep repeat]
fmt.Println(first) // [learn water tip]
}
Why doesn’t tasks change now? Because first has cap == len == 2. That means adding a third element “in place” is impossible. append must create a new backing array and move the data there to fit the new element.
An important nuance: s[a:b:c] does not make the data independent by itself. Until the first append, you still share the backing array, and if you modify existing elements (for example, first[0] = "X"), that will modify the original tasks. The three-index slice protects specifically against writing “into the tail” via growth, not against changing already existing elements.
6. How to choose c and what the cap limit protects
How to read a:b:c so you don’t get confused
So that s[a:b:c] doesn’t look like a spell, keep this simple “map”:
- a — where the window starts (start),
- b — where the length ends (end of len),
- c — where the capacity ends (end of cap).
And all of this is in the coordinate system of the original backing array.
Let’s visualize it with our tasks:
package main
import "fmt"
func main() {
tasks := []string{"a", "b", "c", "d", "e"}
mid := tasks[1:3:3] // start=1, endLen=3, endCap=3
fmt.Println(mid) // [b c]
fmt.Println(len(mid), cap(mid)) // 2 2
}
Here a = 1, b = 3, c = 3. So:
len = b-a = 3-1 = 2
cap = c-a = 3-1 = 2
That is, we took "b", "c" and forbade this view from growing to the right at all.
If you try append(mid, "X"), append won’t be able to write into the tail of the original array (since "d" is there next), and it will be forced to allocate a new backing array.
Mini diagram: what exactly the cap limit protects
Sometimes it’s easier to understand visually. Let’s think of the backing array as a shelf with slots, and a slice as a frame-window.
Suppose:
tasks backing array:
[0] a | [1] b | [2] c | [3] d | [4] e
If you take mid := tasks[1:3], then mid is a window over b, c, but cap usually extends to the end of the backing array:
mid len: 2 elements (b, c)
mid cap: can grow to the right up to e
And append(mid, "X") can write "X" into position [3], overwriting d.
If you take mid := tasks[1:3:3], you’re saying: “my window ends at 3, and the growth ceiling is also at 3”. That is:
mid len: 2 elements (b, c)
mid cap: 2 elements (exactly b, c)
Growing to the right is no longer allowed. That’s the protection.
7. Practice: a safe view for a report and how it differs from a copy
Example: “a report view you can extend”
Now let’s assemble the idea into a scenario that actually shows up in real code. Suppose we have a task list, and we want to build a separate list for output: take the first N tasks and append the string "---" at the end as a separator. We want adding the separator not to change the original task list.
Let’s write a function that returns a “safe” view (with a limited cap). We use functions because that makes the code closer to real-world development.
package main
import "fmt"
func topView(tasks []string, n int) []string {
if n > len(tasks) {
n = len(tasks)
}
return tasks[:n:n] // cap == len, protection against append
}
func main() {
tasks := []string{"learn", "water", "sleep", "repeat"}
view := topView(tasks, 2)
view = append(view, "---")
fmt.Println(tasks) // [learn water sleep repeat]
fmt.Println(view) // [learn water ---]
}
Here we didn’t manually make a copy. We just returned a sub-slice with “trimmed capacity”, so that later append on view can no longer ruin the original array.
But again (this is important): if instead of append you do view[0] = "X", you will modify tasks[0]. Because this is still a view over the same elements. The cap limit is about growth, not about “data ownership”.
Why this is not a copy: honestly distinguishing two strategies
It’s easy to fool yourself: “if append after a cap limit doesn’t change the original slice, then it must be a copy”. No. It’s like saying “I set a speed limiter and now the car doesn’t accelerate”—but it’s still the same car.
A cap limit does two things.
First, it forbids the sub-slice from using someone else’s tail of the backing array as its “growth zone”. That’s exactly what you need so append doesn’t overwrite neighboring elements.
Second, it makes behavior more predictable: as soon as you try to grow the slice via append, it will move to a new backing array. That’s often convenient, because you get a “new memory area” as a side effect of growth.
But if you need independence right now, without any growth, or if you want to guarantee data separation for later element updates, that’s a different strategy. The key is not to confuse them: s[a:b:c] is about controlling capacity, not about physically copying.
8. Common mistakes when using s[a:b:c]
Mistake #1: thinking s[a:b:c] is “some hack” you can ignore.
In practice, it’s official slice-expression syntax, and it exists for a straightforward engineering task: control the resulting cap. It’s not a “competitive programming trick”; it’s a way to make code predictable, especially when a project has many functions that accept/return sub-slices.
Mistake #2: expecting s[a:b:c] to copy elements.
After sub := s[a:b:c], you still share the backing array. If you modify existing elements sub[i] = ..., it affects the original s. The cap limit mainly protects against growth effects via append, but it does not remove aliasing.
Mistake #3: mixing up the index roles and writing “by feel”.
It helps to remember: a is the window start, b is the end of length, c is the end of capacity. If you can’t quickly explain in words what a specific triple a:b:c means, it’s better to stop and compute len = b-a and cap = c-a on paper. That’s faster than debugging “ghost data corruption” later.
Mistake #4: breaking the order a <= b <= c and getting a panic.
A three-index slice is stricter than it looks: you can’t set c less than b, and you can’t set b less than a. Go doesn’t try to “guess what you meant”; it fails honestly because the window boundaries cannot be negative or swapped.
Mistake #5: forgetting that c is compared against cap(s), not len(s).
Beginners often think “but I only have len elements”. Yet cap can be larger, especially if the slice was produced after append or make(..., len, cap). So the validity of c is determined by the capacity of the backing array, not by the current window length.
GO TO FULL VERSION