1. nil slice and empty slice: what’s the difference
When you write a program in Go, you very quickly start storing data as a “list”: numbers, strings, input results, accumulated values in a loop. And almost immediately you run into the situation: “the list is empty.” But empty in what sense? Does it not exist at all (not initialized), or does it exist, but it has 0 elements?
In plain human language it sounds like nitpicking. In a programming language, it’s sometimes an important distinction: it affects checks, what you can compare, and how your code reads to other people (including future you, who will look at this code in a month and think, “who wrote this?”).
Zero value for []T: what a nil slice means
In Go, almost every type has a default value (a zero value). For int it’s 0, for bool it’s false, for string it’s the empty string "". For a slice []T, the zero value is nil. That is, “there is no slice”: it doesn’t point to any elements.
The most common place you’ll see this is a simple variable declaration:
package main
import "fmt"
func main() {
var nums []int
fmt.Println(nums == nil) // true
fmt.Println(len(nums), cap(nums)) // 0 0
fmt.Println(nums) // []
}
Notice a small “gotcha for beginners”: printing fmt.Println(nums) shows []. At a glance this looks like an “empty list.” But technically it’s a nil slice.
It’s important to remember three facts about a nil slice:
nums == nil → true
len(nums) → 0
cap(nums) → 0
Why does this fit the “pointer + len + cap” model? Because pointer = nil, and length and capacity are zero — “there’s nowhere to look and nothing to look at.”
2. Getting to know make
In Go there are two similar-looking words: new and make. At the start it’s easier to remember one thing: make is used to create containers that are ready to use. First of all, slices, maps, and channels. Today we only care about slices.
When you write:
tasks := make([]string, 0)
you are asking Go: “make me a slice of strings of length 0.” That is, there are no elements yet, but the slice object already exists and can be safely passed around, returned from functions, and grown via append.
For slices, make has two forms — and both are read very literally:
make([]T, n) // length n: elements already “exist” and are filled with the zero value
make([]T, n, cap) // length n and extra capacity cap (cap >= n)
For example, this is not an “empty list with 3 slots,” but a list of three elements, just zeros for now:
package main
import "fmt"
func main() {
a := make([]int, 3)
fmt.Println(a, len(a), cap(a)) // [0 0 0] 3 3
}
And this is specifically “empty for now, but with room to grow”:
package main
import "fmt"
func main() {
b := make([]int, 0, 3)
fmt.Println(b, len(b), cap(b)) // [] 0 3
}
And lastly: make is not needed because “you can’t” without it, but because it lets you explicitly choose the slice shape: create length n right away to fill by index, or create an empty slice with extra capacity if you’ll append items gradually.
3. Empty slice: what it is and how to get one
An empty slice is a slice whose len == 0, but at the same time it is not equal to nil. That is, “the slice exists,” it just has zero elements for now.
There are two most popular ways to get an empty slice:
Literal []T{}
This option is convenient when you want to explicitly show in code: “this is an empty collection, but it’s a collection.”
package main
import "fmt"
func main() {
tasks := []string{}
fmt.Println(tasks == nil) // false
fmt.Println(len(tasks), cap(tasks)) // 0 0
fmt.Println(tasks) // []
}
make([]T, 0)
This is almost the same idea, but it looks like “we’re creating a slice programmatically.”
package main
import "fmt"
func main() {
tasks := make([]string, 0)
fmt.Println(tasks == nil) // false
fmt.Println(len(tasks), cap(tasks)) // 0 0
}
Technically, []string{} and make([]string, 0) usually lead to the same picture: length 0, capacity 0, not nil. But in your head you can keep a simple association: a literal is “an empty value in code,” make is “we created a container.”
Cheat sheet: nil vs empty
When the differences start to blur, it’s best to stop for a minute and check a simple table. It saves a lot of time and nerves.
| Slice state | How to create | s == nil | len(s) | cap(s) | Printing fmt.Println(s) |
|---|---|---|---|---|---|
| nil slice | |
|
|
|
|
| empty slice | |
|
|
|
|
| empty with extra capacity | |
|
|
|
|
Pay special attention to the third row: len == 0, but cap can be large. This is normal: “there are no elements yet, but space for growth is prepared.”
4. Checks and initialization: len(s) == 0 and s == nil
Which checks are correct: len(s) == 0 or s == nil
When you check a slice, it’s important to ask yourself an honest question: what exactly do I want to know? This is where the most common mistakes start.
If you want to know “are there any elements,” then the correct question to the code is: is the length zero or not? So the check should use len.
if len(tasks) == 0 {
fmt.Println("No tasks yet") // No tasks yet
}
If you check tasks == nil, you’re asking something else: “is the slice uninitialized / absent as a value?” This can matter, but it’s about the meaning of the data, not about whether elements exist.
To feel the difference, let’s look at an example where both checks produce different results:
package main
import "fmt"
func main() {
var a []int // nil
b := []int{} // empty
fmt.Println(len(a) == 0, a == nil) // true true
fmt.Println(len(b) == 0, b == nil) // true false
}
And now the rule that usually makes a beginner’s code calmer:
- If you need to “check whether the list is empty,” check len(s) == 0.
- If you need to “check whether the slice is initialized,” check s == nil.
When s == nil really makes sense
Sometimes the nil vs empty distinction matters, just not that often in the very first programs.
Imagine you have a function that optionally collects data. If there is no data at all (for example, the user didn’t enable this option), you want to return nil to indicate “the value is absent.” But if the user enabled the option and got zero results, then return empty to indicate “the value exists, but it’s empty.”
Let’s model this without complicated topics:
package main
import "fmt"
func buildHints(enabled bool) []string {
if !enabled {
return nil // hints are disabled
}
return []string{} // enabled, but empty for now
}
func main() {
a := buildHints(false)
b := buildHints(true)
fmt.Println(a == nil, len(a)) // true 0
fmt.Println(b == nil, len(b)) // false 0
}
Here len is the same (0), but the meaning is different. And this is exactly the situation where == nil is useful: not for “emptiness,” but for “presence of a value.”
Careful initialization: what to choose in real code
When you start writing functions and passing slices between them, you develop a style. It’s better to form it now while the program is small.
If you build a list via append (more on it in the next lecture) and you don’t have requirements about preallocating memory, then var s []T is an absolutely normal starting point. It’s short, readable, and doesn’t force you to think about make prematurely.
If you want to explicitly show “this is definitely a collection, even if it’s empty,” then s := []T{} is also a good option. You often see it when returning a value from a function and you want to guarantee that the caller gets a non-nil slice.
If you know ahead of time that you’ll add about N elements and you don’t want append to grow the internal buffer multiple times, then make([]T, 0, n) comes into play. But that’s more about efficiency and predictable growth, and in today’s lecture what matters more is the meaning of nil vs empty, not optimizations.
5. What is safe to do with a nil slice
When people first see a nil slice, they often feel anxious: “If it’s nil, everything will crash.” Good news: Go is quite friendly to nil slices. You can use them safely in many places.
len, cap, and range are safe
Since len(nilSlice) == 0, the loop just won’t run:
package main
import "fmt"
func main() {
var nums []int // nil
sum := 0
for _, v := range nums {
sum += v
}
fmt.Println(sum) // 0
}
This is a very important practical detail: because of it, you can often keep a nil slice as an “empty list by default” without adding extra ifs.
Indexing s[0] is NOT safe when len(s) == 0
This is where everyone crashes, including experienced developers—just less often. Indexing only works within the range 0..len(s)-1. So both nil and empty are equally dangerous for s[0] when the length is zero.
package main
import "fmt"
func main() {
var nums []int // nil
if len(nums) > 0 {
fmt.Println(nums[0])
} else {
fmt.Println("No first element") // No first element
}
}
Why nil and empty look the same when printed
When fmt.Println prints a slice, it prints the “representation of the elements.” If there are zero elements, from an output perspective there’s almost no difference: “there’s nothing to print.”
But internally the program distinguishes two states:
- nil slice: “no backing array, nil pointer”
- empty slice: “a backing array may exist (or be optimized away), but length is 0”
In terms of the slice model (pointer/len/cap), this means: for nil the pointer is “empty,” and for empty the pointer may be non-nil while the length is still 0. That’s why s == nil separates these cases, and len(s) == 0 does not.
If you want to “see” the difference during debugging, it’s handy to use Printf and print len, cap, and the == nil check:
package main
import "fmt"
func main() {
var a []int
b := []int{}
c := make([]int, 0, 3)
fmt.Printf("a: len=%d cap=%d nil=%v\n", len(a), cap(a), a == nil) // a: len=0 cap=0 nil=true
fmt.Printf("b: len=%d cap=%d nil=%v\n", len(b), cap(b), b == nil) // b: len=0 cap=0 nil=false
fmt.Printf("c: len=%d cap=%d nil=%v\n", len(c), cap(c), c == nil) // c: len=0 cap=3 nil=false
}
6. Example: a task list in a learning app
Now is a perfect moment to tie the topic to something “alive,” not abstract nums. Let’s imagine we’re writing a simple console program “TaskBox”: it reads tasks and prints them. Before slices, many people would try to store tasks in a fixed-size array, but now it makes more sense to use []string.
The main goal of this section is to feel that a nil slice doesn’t prevent normal operation if we do the checks correctly.
Printing the task list: the correct “empty” check
Now let’s make a small function that prints tasks nicely. Important: it must work equally correctly for both nil and empty.
package main
import "fmt"
func printTasks(tasks []string) {
if len(tasks) == 0 {
fmt.Println("Task list is empty") // Task list is empty
return
}
for i, t := range tasks {
fmt.Printf("%d) %s\n", i+1, t)
}
}
func main() {
var tasks []string // nil
printTasks(tasks)
tasks = append(tasks, "Make lecture 53 clear") // append new text to the end of the slice
printTasks(tasks)
}
Key point: we don’t ask tasks == nil. We don’t care why there are no tasks—because the list wasn’t created or because it was created empty. We only care whether there are elements.
7. Typical mistakes: nil slice and empty slice
Mistake #1: checking “emptiness” via s == nil.
This code works “sometimes”—right up until someone creates s := []T{} or make([]T, 0). The slice will be empty but not nil, and the check will break the logic. If you want to know whether there are elements, ask that question directly: len(s) == 0.
Mistake #2: thinking that fmt.Println(s) shows the difference between nil and empty.
Both options are most often printed as [], and visually it seems like the same thing. If the distinction matters, print s == nil and len/cap via Printf, otherwise you’ll be debugging “by a shadow on the wall.”
Mistake #3: indexing without checking the length.
It’s very tempting to write first := s[0] and assume “well, I do have a list.” But nil and empty crash equally on s[0] when len(s) == 0. A reliable pattern: first if len(s) == 0 { ... }, then indexing.
Mistake #4: complicating code with nil checks where len is enough.
Beginners sometimes write if s != nil && len(s) > 0 { ... }. The second part (len(s) > 0) already guarantees that the slice has elements. An extra nil check doesn’t make the code safer; it just makes it longer and less readable. In Go, the most direct question to the data often wins: “how many elements?”
GO TO FULL VERSION