1. Introduction
When you are just starting to write Go, slices look “like a dynamic array”: you have []int, you can do s[i], you can iterate in a loop, you can store many elements. And everything would be fine if slices didn’t sometimes behave… like living things. Sometimes the same slice is suddenly “visible” through two variables, sometimes the length and capacity are somehow different, sometimes the indexing boundaries don’t match what you “feel”.
To stop guessing and start predicting behavior, Go programmers keep a simple model in mind: a slice is not the elements themselves, but a small descriptor structure that points to where the elements live and how many are available. This model is called the slice header.
Slice as a “handle” to data: pointer + len + cap
Here is an important idea that is worth rereading twice, especially if you are tired: a slice value []T is not a “container with elements”, but a description of a window through which we look at some set of elements in memory.
This “description” (slice header) logically has three parts:
| Part | How to read it in plain English | What it affects in behavior |
|---|---|---|
|
“where we are looking” | why two slices can see the same elements |
|
“how many elements are visible” | indexing boundaries 0..len-1, how many iterations range performs |
|
“how much extra space exists beyond” | why a slice can have capacity greater than length |
You can imagine it like this (conceptually, not as an actual compiler printout):
slice header:
+---------+-----+-----+
| pointer | len | cap |
+---------+-----+-----+
Important: in this lecture we will not dive into “real pointers” in code (&, *, unsafe). For now, it is enough to understand the idea: inside there is a “reference to data”.
2. len: visible length and indexing boundaries
When you write len(s), you ask the slice: how many elements are currently accessible through this window. And that number is the law. Not an “approximate guideline”, not “how many we plan to put there”, not “how many exist somewhere out there”, but exactly: how many you can safely read/write by index.
len determines the valid indices:
- first index: 0
- last index: len(s) - 1
- index len(s) is already out of bounds (and will cause a panic when accessed)
A small example to visually see the relationship between len and indices:
package main
import "fmt"
func main() {
s := []int{10, 20, 30}
fmt.Println(len(s)) // 3
fmt.Println(s[0]) // 10
fmt.Println(s[2]) // 30
fmt.Println(s[3]) // panic: index out of range
}
If you previously worked with languages where “somewhere over there” they return null or undefined, Go will quickly train you otherwise: going out of bounds is not a “soft error”, it is an emergency stop of the program (panic). And honestly, in learning tasks this is even useful: the error is visible immediately.
4. cap: capacity, slack, and why it is not “more elements”
Capacity (cap) typically triggers exactly two emotions in beginners: “why?” and “why isn’t it equal to len?” Both emotions are normal.
Let’s agree on the correct interpretation:
cap(s) is how many elements can fit in this same memory region, starting from the current beginning of the slice, without moving elsewhere.
That is, cap is “slack” that helps the slice grow efficiently. But (and this is important) cap does not give you the right to index. Indexing is governed by len, while cap is more about internal mechanics and performance.
To see the situation where “len is less than cap”, we need a way to create a slice so that it is “shorter than the slack”. The simplest way is make, but here we use it only as a tool to demonstrate len/cap, without going deep into details.
package main
import "fmt"
func main() {
s := make([]int, 2, 5) // len=2, cap=5
fmt.Println(len(s), cap(s)) // 2 5
fmt.Println(s) // [0 0]
}
Note: in the example above we used the make function. We will cover it in detail in the next lecture, and here it is needed only to create a slice with different len and cap.
Here is what we got in the example above:
- len=2 means: there are two accessible elements, both currently zero (the zero value for int).
- cap=5 means: this same block of memory can hold up to 5 elements, but right now only 2 are visible.
If you try to access s[2], you will get a panic, even though cap=5. And that is logical: slack does not equal access.
package main
import "fmt"
func main() {
s := make([]int, 0, 3) // len=0, cap=3
fmt.Println(len(s), cap(s)) // 0 3
// fmt.Println(s[0]) // panic: len=0, cannot index
fmt.Println(s) // []
}
Remember this short formula:
- len is responsible for correct access to elements;
- cap is responsible for how “comfortably” the slice can grow.
5. pointer: why two slices can see the same elements
Now the most “magical” part: pointer in the header. The word sounds scary, but the idea is simple. Inside the slice there is information about where the first element is stored (roughly: “the address of the start of data”). Therefore, two different slice values can reference the same set of elements.
That is exactly what explains this behavior:
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
t := s // copy the header, not the elements
t[0] = 99
fmt.Println(s) // [99 2 3]
fmt.Println(t) // [99 2 3]
}
From the slice header model point of view, the following happens:
- t := s copies only the header (pointer/len/cap).
- pointer in t and in s points to the same backing array.
- therefore changing t[0] changes the data “underneath”, and this is visible through s.
You can draw a diagram. It is conceptual, but useful:
s (header) ----+
|
v
[ 1 ][ 2 ][ 3 ] (data)
^
|
t (header) ----+
Until you internalize this picture, slices will seem “unpredictable”. Once you do, they become very logical.
6. Why a slice is copied “cheaply”
An important point: in Go everything is passed by value. This means that on assignment or when passing an argument to a function, Go copies the variable’s value.
But here is the trick: an array value and a slice value have different “weight”.
- For an array [N]int, the value contains N elements.
- For a slice []int, the value contains a header of three parts (pointer/len/cap).
So copying an array means copying all elements, while copying a slice means copying a “small descriptor card”.
Let’s see this through a function. First, an example where we change an element:
package main
import "fmt"
func setFirst(s []int) {
s[0] = 100
}
func main() {
nums := []int{1, 2, 3}
setFirst(nums)
fmt.Println(nums) // [100 2 3]
}
Why are the changes visible outside? Because the function received a copy of the header, but pointer in that header still points to the same data.
It is useful to say it carefully: the function did not receive a “reference to the slice” (we have not studied pointers yet). The function received a copy of the value; it is just that this value is a small descriptor that contains a pointer to shared data.
7. Practice: observing len and cap in code
A mini debugging tool: printing slice state
When you are learning, your brain often asks: “Can I see it in numbers?”. You can. And you should—especially in Go, where fmt.Printf is great for diagnostics.
Let’s make a small function printSliceState that we will use in our learning app. The app will be simple: we read several numbers (for example, expenses or grades), put them into a slice, and sometimes print diagnostics.
package main
import "fmt"
func printSliceState(name string, s []int) {
fmt.Printf("%s: len=%d cap=%d data=%v\n", name, len(s), cap(s), s)
}
func main() {
a := make([]int, 2, 5)
printSliceState("a", a) // a: len=2 cap=5 data=[0 0]
}
Note: we do not print the pointer (and that is fine). At this level we can observe len and cap directly, and understand “pointer” through effects: shared elements, changes visible through different variables, etc.
How the model helps you write predictable code in an application
When you build a program that stores a dataset, you usually have two scenarios.
First scenario — you know in advance how many elements there will be (for example, the user enters n, and you read exactly n numbers). Then it is convenient to create a slice of length n and fill it by index. In this scenario, len is “how many elements actually exist”, and you fill them once.
package main
import (
"fmt"
)
func main() {
var n int
fmt.Scan(&n)
nums := make([]int, n) // len=n, cap=n
for i := 0; i < len(nums); i++ {
fmt.Scan(&nums[i])
}
fmt.Println(nums) // for example: [5 10 15]
}
Here the header model helps because you do not get confused: len(nums) is “how many numbers we read” and “how many indices are available”. Everything is consistent.
Second scenario — you do not know how many elements there will be, and you collect them gradually. Then you usually choose a different style (appending to the end). We will cover that style in detail a bit later; for now it is important that cap is the parameter that makes gradual growth efficient.
By the way, a historical fact: before convenient append mechanics existed, programmers often wrote a manual check like if n+1 > cap(list) { ... }, allocated a new buffer and copied data. You can encounter such code (and its motivation) in stories about Go’s evolution.
Mental checklist: how to “read” any slice
A very useful habit (especially in tasks and interviews): when you see a slice, ask yourself three questions.
First question: where is the data? (that same pointer, i.e., “do I share a backing array with someone else?”). This question saves you from surprises like “why did it change somewhere else?”.
Second question: how many elements do I see? (this is len). This question saves you from panic: index out of range.
Third question: do I have slack, and why do I need it? (this is cap). This question saves you from blindly believing that adding elements is “always free”, and helps you write code that does not turn into a microwave heating up your RAM.
If you want an even more “tangible” feel for the difference between len and cap, you can add diagnostic printing in your app before and after filling to see that cap does not have to match len:
package main
import "fmt"
func main() {
s := make([]int, 2, 5)
fmt.Println(len(s), cap(s), s) // 2 5 [0 0]
s[0] = 7
s[1] = 8
fmt.Println(len(s), cap(s), s) // 2 5 [7 8]
}
You can see something important here: filling elements does not change len or cap. That is logical: the header does not have to change just because you changed the underlying data.
8. Typical mistakes when understanding the slice header
Mistake #1: thinking that t := s copies the elements.
This is one of the most common traps. A beginner expects that since they have a “new variable”, it must be a “new list”. With a slice, the header is copied, while the data often remains shared. As a result, changing t[0] can change s[0]. To avoid surprises, it helps to say to yourself: “I created a second handle to the same data”.
Mistake #2: confusing cap with the “number of elements” and indexing by cap.
Sometimes a student sees cap=10 and decides: “Oh, I have 10 elements”, and then does s[9]. But if len=2, you just tried to jump to the 9th floor of a building that has only the first two floors built. Go reacts predictably: panic. The rule is simple: index only up to len; slack cap by itself does not grant access.
Mistake #3: believing that len is “how much I have filled in already”, rather than “how many elements exist”.
This is especially noticeable with make([]int, n): many people think it is an “empty list with capacity n”. In reality it is a slice of length n, and it already contains n elements with default values. If you want “empty for now, but with slack”, that is a different scenario. For now it is enough to remember the idea: len represents elements that really exist, even if they are zeros.
Mistake #4: being scared by the word pointer and rejecting the model because of it.
The word “pointer” scares many people, because in other languages or in C/C++ it is associated with pain and suffering (and sometimes with nightmares). But in the context of the slice header, this word describes only one fact: “inside, a reference to the beginning of the data is stored”. You do not need to be able to manipulate addresses manually to correctly understand slices. It is enough to remember that a shared pointer means shared elements.
Mistake #5: not using len as the single source of truth for loops.
Sometimes people write a loop based on an “expected size”, an entered n, or some constant, and forget that the slice may already have a different length. This causes either out-of-bounds access or skipped elements. The habit “the loop always goes up to len(s)” makes code boring—and boring code, as we know, breaks less often (which is almost a compliment).
GO TO FULL VERSION