1. Slices: how they work and where they break
When you’re just starting out, it’s easy to fall into a trap: “well, it printed the correct answer on two tests—so it must be fine.” Slices and maps are especially tricky because their bugs often don’t show up immediately: a bug may appear only on different data, in another code path, or after a small refactor. The review here is not for the sake of boring theory, but so that you learn to predict program behavior.
Useful thought of the day: slices and maps are not just “containers,” they’re containers with rules. If you keep those rules in your head, you write code faster and debug more calmly. If you don’t, the shamanic dancing begins: “why did my original slice change?”, “why is the map output different every time?”, “why does append sometimes corrupt data?”.
Slice model: a “window” into an array
In Go, it helps to think of a slice not as a “list,” but as a window through which we look at a portion of an array. That window is described by three numbers: where the data starts (pointer), how many elements we consider “visible” (len), and how much we can “grow” without moving (cap). Because of this model, append is sometimes safe, and sometimes it’s like renovating an apartment: you think “I’ll just patch this a bit,” and you end up moving out.
Slices can share the same array, overlap, and affect each other. The internal “pointer/len/cap” structure is the key to understanding almost all oddities with append, sub-slices, and copying. This is exactly the kind of case where one simple picture saves you hours of debugging.
You can picture it like this:
flowchart LR
A[Underlying array] --> B[Slice header]
B --> P[pointer -> start index]
B --> L[len]
B --> C[cap]
nil slice vs empty slice: same length, different meaning
The nil vs empty topic seems minor—until you start writing careful checks and run into “why is == nil false?”. In Go, both variants are allowed: var s []int (this is nil) and s := []int{} (this is empty, but not nil). In most applied tasks they behave the same: len will be 0, range will do nothing, and append will work. But sometimes the difference matters as a signal of intent: “value is absent” vs “value exists but is empty.”
Let’s look at a small example and also reinforce that append can work with a nil slice (and that’s normal):
package main
import "fmt"
func main() {
var a []int // nil
b := []int{} // empty
fmt.Println(a == nil, len(a), cap(a)) // true 0 0
fmt.Println(b == nil, len(b), cap(b)) // false 0 0
a = append(a, 10)
b = append(b, 10)
fmt.Println(a) // [10]
fmt.Println(b) // [10]
}
For reinforcement—a compact table:
| Property | (nil) |
(empty) |
|---|---|---|
|
|
|
|
0 iterations | 0 iterations |
|
works | works |
|
|
|
| Meaning | “not set” | “set, but empty” |
append: why you must store the result
If we were to pick “the most expensive beginner mistake” by the ratio of “simplicity → nerves wasted,” it would be ignoring the result of append. append returns a new slice, because it may change the length and sometimes also the data buffer (allocate a new array and copy elements). So the correct form is almost always: s = append(s, x).
You don’t need to memorize the internal growth algorithms, but you do need to remember the behavioral contract: “after append, the old slice may no longer be what it was.” Sometimes it will point to the same array; sometimes to a new one. And that’s exactly why code that “seemed to work” starts breaking after a small change, like adding one more append somewhere nearby.
A mini attention-check example:
package main
import "fmt"
func main() {
s := make([]int, 0, 2)
s = append(s, 1)
s = append(s, 2)
t := append(s, 3) // key point: store it in t
fmt.Println("s:", s) // s: [1 2]
fmt.Println("t:", t) // t: [1 2 3]
}
If you write just append(s, 3), then the slice s might remain length 2, or it might “somehow” change—and you’d get a hard-to-explain picture.
Sub-slices and aliasing: “why did the original change?!”
Sub-slices (for example, sub := base[a:b]) are incredibly convenient… until you start modifying them. The main idea: a sub-slice almost always shares the underlying array with the base slice. That means writing into elements of the sub-slice changes the original array. That’s expected when you do sub[0] = 99, but much less expected when you do append(sub, ...) and suddenly the “tail” of base changes.
Here’s an example worth running through with your eyes many times:
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
sub := base[1:3] // [2 3]
sub = append(sub, 99) // may affect base
fmt.Println("base:", base)
fmt.Println("sub :", sub)
}
Why “may”? Because it all comes down to cap(sub). If cap allows “writing more” into the same array, Go will write there—and overwrite elements of the base array after the sub window. This behavior is completely logical if you remember the header with cap.
Full slice expression s[a:b:c]: the official way to put up a fence
A full slice expression looks like a magic trick, but conceptually it’s just a more precise description of the window: you specify not only len, but also the maximum capacity (cap) for the resulting sub-slice. The idea is simple: “here is the visible part a:b, and please don’t go past c into the original array with append.”
Practically, it’s a good tool to protect against accidental aliasing: if you’re going to append to a sub-slice and don’t want to modify the original, limit cap so that append is forced to allocate a new array.
package main
import "fmt"
func main() {
base := []int{1, 2, 3, 4}
sub := base[1:3:3] // len=2, cap=2
sub = append(sub, 99) // cap not enough -> new array
fmt.Println("base:", base) // base: [1 2 3 4]
fmt.Println("sub :", sub) // sub : [2 3 99]
}
copy as an ownership boundary: “now it’s mine”
Once you understand that slices can share data, the next logical step is to learn to make an explicit copy when needed. In Go, copying is not done via assignment (that only copies the header), but by creating a new slice and using copy. This is useful when you want to keep a “snapshot” of data that won’t change even if the original slice is modified later.
The classic form:
package main
import "fmt"
func main() {
orig := []string{"a", "b", "c"}
clone := make([]string, len(orig))
copy(clone, orig)
orig[0] = "X"
fmt.Println(orig) // [X b c]
fmt.Println(clone) // [a b c]
}
At this point many people ask: “why can’t I just do clone := orig?”. Because that would be a second header pointing to the same array—i.e., aliasing again, just more hidden.
Deleting from a slice and the “tail”: why you sometimes should clear it
Deleting elements from the middle of a slice is a topic where it’s easy to make off-by-one mistakes—and even easier to forget that the underlying array may still hold references to “deleted” elements beyond len. At the “mental model” level it’s useful to remember: the length got smaller, but the array memory can still contain old values until you nil/zero them out (important for slices of pointers, strings, other slices, etc.).
In modern versions of Go, the standard package slices takes care of part of this work: some operations (for example slices.Delete) clear the “tail” so you don’t accidentally retain memory.
But the key skill remains the same: if a function returns a new slice, you must use it.
package main
import (
"fmt"
"slices"
)
func main() {
s := []int{10, 20, 30, 40}
s = slices.Delete(s, 1, 3) // deleted elements with indexes 1..2
fmt.Println(s) // [10 40]
}
If you write slices.Delete(s, 1, 3) and don’t assign the result, you’ll get a “weird” slice: the length is old, the contents are partially shifted—and then the classic monologue starts: “Go must be messing with me.”
3. Maps: the model and practical rules
What is guaranteed—and what isn’t
A map in Go is a “key → value” structure, and it’s great for fast existence checks, counting, set patterns, and other practical things. But maps have two rules you should keep in mind all the time: first, a nil map cannot be modified (writes cause panic), and second, the iteration order of range over a map is not part of the contract.
This isn’t a “quirk of a specific implementation,” it’s a deliberate rule: code must not rely on iteration order, otherwise you’ll get unstable behavior. In real development, unstable output is painful: tests start flaking, diffs in logs become meaningless, and users ask questions like “why does the list jump around?”.
nil map: you can read, you can’t write
A nil map is like an empty notebook behind glass: you can read the headings (there aren’t any anyway), but you can’t write. On the other hand, range over a nil map is safe—there will just be no iterations.
package main
import "fmt"
func main() {
var m map[string]int // nil map
fmt.Println(m["a"]) // 0 (zero value), but the key is absent
// m["a"] = 1 // panic: assignment to entry in nil map
m = make(map[string]int)
m["a"] = 1
fmt.Println(m["a"]) // 1
}
Here it’s especially important not to confuse “zero value from a map” with “key is absent.” That’s why the next part is about the ok idiom.
v, ok := m[k]: how to distinguish “no key” from the zero value
If a value of type V has a reasonable zero value (for example, 0 for int), then simply reading m[k] doesn’t let you tell whether it was really 0 or whether the key was absent. That’s why the idiom v, ok := m[k] is not just a “language trick,” but real protection against logic bugs.
package main
import "fmt"
func main() {
m := map[string]int{"a": 0}
v1, ok1 := m["a"]
v2, ok2 := m["b"]
fmt.Println(v1, ok1) // 0 true
fmt.Println(v2, ok2) // 0 false
}
In application code this saves you from nasty bugs where the program “seems to see a value,” but the key is actually absent and you just got the zero value.
Order of range over a map and stable output: sort keys
When you print data from a map (to the console, to a report, in tests), you almost always want the order to be the same. Go does not guarantee that, so we use a simple protocol: collect keys into a slice → sort → print in order. Go has convenient tools for sorting slices; the standard library includes the slices package with sorting and utilities.
Mini example:
package main
import (
"fmt"
"slices"
)
func main() {
m := map[string]int{"b": 2, "a": 1, "c": 3}
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, m[k])
}
}
Treat this pattern as “the norm,” not as “we’ll optimize it later.” You almost never need optimization here: sorting keys typically costs less than your time spent investigating unstable results.
4. Mini app “Shopping list”
Let’s build a small example that resembles real educational console programs: there’s a list of strings (a slice), there’s counting repeats (a map), and there are a few places where it’s easy to make typical mistakes. We intentionally don’t use structs—they’ll come later; for now we work with the tools we already have.
We’ll start by adding purchases to a slice while simultaneously counting how many times each word occurs (for example, to see “what people buy most often”).
package main
import (
"fmt"
"strings"
)
func addItem(items []string, counts map[string]int, raw string) []string {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
return items
}
items = append(items, name)
counts[name]++
return items
}
func main() {
items := make([]string, 0, 4)
counts := make(map[string]int)
items = addItem(items, counts, " Milk ")
items = addItem(items, counts, "bread")
items = addItem(items, counts, "milk")
fmt.Println(items) // [milk bread milk]
fmt.Println(counts) // map[bread:1 milk:2] (order may differ)
}
This code already hides two “important reviews”: append returns a new slice, so items = addItem(...) is the normal form; and map output is “whatever happens,” so for nice and stable output you need to sort the keys.
Stable printing of the counter:
package main
import (
"fmt"
"slices"
)
func printCounts(counts map[string]int) {
keys := make([]string, 0, len(counts))
for k := range counts {
keys = append(keys, k)
}
slices.Sort(keys)
for _, k := range keys {
fmt.Println(k, counts[k])
}
}
Notice that keys is a slice, and here you again practice the habit “always store append’s result.”
5. Typical mistakes
Mistake #1: ignore the result of append.
It looks harmless: “I just added an element.” But append returns a new slice, and if you don’t store it, you keep working with the old header: with the old length and potentially already modified data. The habit is almost mechanical: you see append—you ask yourself “where did I store the result?”.
Mistake #2: think that sub := base[a:b] is a copy.
A sub-slice is almost always a “view” into the same data. The problem shows up not when you read sub, but when you write into sub or do append(sub, ...). As a result, base may change, and you’ll look for “who ruined the data,” even though it was you—just via a shared underlying array. If you need to detach, use copy or limit cap via base[a:b:c].
Mistake #3: write to a nil map.
var m map[K]V creates a nil map. You can read, you can range, but writing causes panic. It’s not because Go is mean; it’s because “there is no allocated memory for the table.” Fixed in one line: m = make(map[K]V) before the first write.
Mistake #4: rely on the order of range over a map.
Sometimes you want to think: “I only have three keys, and they always print in the same order… so far.” That feeling is deceptive. Order is not guaranteed, and you can’t make it part of your logic. If you need stable order for output or tests, use the protocol “keys into a slice → sort → output.”
Mistake #5: confuse “key is absent” with “value equals the zero value.”
If you write v := m[k] and then draw conclusions, for int you can’t distinguish “key is absent” from “key exists and its value is 0.” This is a classic source of bugs in counters, flags, and statuses. In Go, the standard answer is: v, ok := m[k]. If ok == false, then the key wasn’t there.
GO TO FULL VERSION