Go slices and maps are the two collection types you'll use in almost every Go program, and pointers decide whether a function works on your data or on a copy of it. The rule that ties them together is simple: Go always passes by value. What gets copied, though, differs. Copying an array copies every element. Copying a slice copies a small header that still points at the same backing array. Copying a map copies a reference to the same map.
This guide starts with Go's control flow basics, then covers arrays vs slices (len, cap, append, nil vs empty), maps, and pointers, with the gotchas that cause real bugs. Examples target Go 1.22 or newer.
Control flow basics in Go
if with an init statement
No parentheses around the condition, braces always required. An optional init statement scopes a variable to the if/else chain:
if n, err := strconv.Atoi(input); err != nil {
return fmt.Errorf("invalid number: %w", err)
} else if n > 100 {
return errors.New("too large")
}for is the only loop
Go has no while or do-while. The for keyword covers every loop shape:
// classic three-part loop
for i := 0; i < 3; i++ {
fmt.Println(i)
}
// "while" loop
for attempts < 5 {
attempts++
}
// forever loop: exit with break or return
for {
if done() {
break
}
}
// range over an int (Go 1.22+): 0, 1, 2
for i := range 3 {
fmt.Println(i)
}
// range over a slice: index and value
for i, name := range []string{"a", "b"} {
fmt.Println(i, name)
}Since Go 1.22, each loop iteration gets its own copy of the loop variable. Closures and goroutines that capture i or name now see the value from their own iteration, which removes a long-standing class of bugs.
switch without fallthrough
Cases don't fall through by default, a case can list several values, and a switch with no expression works like a clean if/else chain:
switch day {
case "sat", "sun":
fmt.Println("weekend")
default:
fmt.Println("weekday")
}
switch {
case score >= 80:
grade = "A"
case score >= 70:
grade = "B"
default:
grade = "C"
}Use the fallthrough keyword in the rare case you want the next case to run too.
Functions and multiple return values
Functions can return several values, which is how Go reports errors:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}Arrays vs slices in Go
An array has a fixed length that is part of its type. [3]int and [4]int are different types, and assigning or passing an array copies every element:
a := [3]int{1, 2, 3}
b := a // full copy
b[0] = 99
fmt.Println(a) // [1 2 3]Arrays are useful for fixed-size data like a SHA-256 digest ([32]byte), but most code uses slices. A slice is a small header with three fields: a pointer to a backing array, a length and a capacity.
s := []int{1, 2, 3} // literal
t := make([]int, 0, 10) // len 0, cap 10
u := s[1:3] // [2 3], shares s's backing arraylen, cap and append
len is how many elements the slice currently holds. cap is how many fit before the backing array must grow. append adds elements, and when capacity runs out it allocates a larger array and copies the old elements over:
var s []int
for i := range 5 {
s = append(s, i)
fmt.Println(len(s), cap(s))
}The exact growth policy is a runtime detail, roughly doubling for small slices and growing more slowly for large ones. Two rules matter more:
- Always assign the result of
append. It may return a slice pointing to a new array. - Preallocate when you know the size.
make([]T, 0, n)avoids repeated reallocation.
The shared backing array gotcha
Sub-slices share memory with the original. If the sub-slice has spare capacity, append writes into the original's array:
a := []int{1, 2, 3, 4}
b := a[:2] // len 2, cap 4
b = append(b, 99) // fits in capacity: overwrites a[2]
fmt.Println(a) // [1 2 99 4]To stop this, cap the sub-slice with a full slice expression, or copy it:
b := a[:2:2] // len 2, cap 2: the next append must allocate
c := slices.Clone(a[:2]) // independent copyThis bug shows up when a function returns a sub-slice of an internal buffer and the caller appends to it.
Empty slice vs nil slice
| nil slice | empty slice | |
|---|---|---|
| Declared as | var s []int | s := []int{} or make([]int, 0) |
s == nil | true | false |
len(s), cap(s) | 0, 0 | 0, 0 (or more with make) |
append, range, len | all work | all work |
json.Marshal | null | [] |
In Go code the two behave the same, so var s []int is the idiomatic default. Check emptiness with len(s) == 0, never s == nil. The difference that bites is JSON: an API that returns null instead of [] can break frontend code expecting an array. Initialize with []T{} when the value is serialized.
The slices package
Since Go 1.21, the standard slices package covers common operations that used to need hand-written loops:
slices.Contains(names, "ana")
slices.Index(names, "ana") // -1 if missing
slices.Sort(nums)
slices.Reverse(nums)
slices.Max(nums) // panics on an empty sliceMaps in Go
A map is a hash table from keys to values. Keys must be comparable (strings, numbers, structs of comparable fields, but not slices or maps).
stock := map[string]int{"apple": 5, "pear": 2}
stock["mango"] = 7
delete(stock, "pear")
qty := stock["kiwi"] // 0: missing keys return the zero value
qty, ok := stock["kiwi"] // ok == false tells you the key is missing
if !ok {
fmt.Println("no kiwi")
}Rules to keep in mind:
- A nil map can be read but not written.
var m map[string]int; m["a"] = 1panics. Create maps withmakeor a literal. - Iteration order is not defined. Go deliberately varies it between runs. If you need a stable order, sort the keys:
keys := slices.Sorted(maps.Keys(stock)) // Go 1.23+
for _, k := range keys {
fmt.Println(k, stock[k])
}On Go 1.22, collect the keys into a slice with a for range loop and call slices.Sort.
- Maps are not safe for concurrent writes. Two goroutines writing to the same map without a lock crash the program with
fatal error: concurrent map writes. Use async.Mutex, orsync.Mapfor specific read-heavy cases. Go concurrency in production covers the patterns. - Sets are usually
map[string]struct{}. The empty struct takes no space. clear(m)(Go 1.21+) removes every entry and keeps the allocated memory for reuse.
Pointers in Go
A pointer holds the memory address of a value. & takes an address and * reads or writes through it:
i := 20
p := &i // p has type *int
fmt.Println(*p) // 20
*p = 21
fmt.Println(i) // 21
var q *int // zero value is nil
fmt.Println(q == nil) // true; *q would panic: nil pointer dereference
n := new(int) // allocates a zeroed int, returns *int
fmt.Println(*n) // 0Go has no pointer arithmetic, and it's safe to return a pointer to a local variable. The compiler's escape analysis moves it to the heap when needed. Fields are accessed through a pointer without explicit dereferencing: u.Name works when u is a *User.
Pass by value, and what it means for slices and maps
Every argument is copied. For an int or a struct, the function gets its own copy, so to modify the caller's value you pass a pointer:
func reset(n *int) { *n = 0 }
x := 5
reset(&x)
fmt.Println(x) // 0For slices and maps the copied value contains a pointer, which leads to results that surprise people:
func setFirst(s []int) { s[0] = 100 } // caller sees this
func add(s []int) { s = append(s, 4) } // caller does NOT see this
func put(m map[string]int) { m["k"] = 1 } // caller sees this
nums := []int{1, 2, 3}
setFirst(nums) // [100 2 3]
add(nums) // still [100 2 3]setFirst writes through the shared backing array. add changes the length in its own copy of the header, so the caller's slice never sees the new element. Return the new slice instead, like append does: nums = add(nums).
When to use pointers:
- The function must modify the caller's value.
- The struct is large enough that copying it on every call is wasteful.
- You need to express "no value" with
nil, for example an optional field.
Don't use a pointer to a slice or map just to avoid copying. The header is already small.
A related housekeeping tip: unused imports are compile errors in Go, and your editor's goimports or gopls removes them on save. Unused module dependencies are different. Run go mod tidy to drop them from go.mod. See Go modules and packages for more.
FAQ
Does Go have a while loop?
No. Write for condition { } for a while loop and for { } for an infinite loop.
What is the difference between a nil slice and an empty slice in Go?
A nil slice has no backing array and equals nil. An empty slice has one of zero length. They behave the same with len, append and range, but encode to JSON as null and [] respectively.
Is Go pass by reference?
No, Go is always pass by value. Slices, maps, channels and pointers contain references, so copying them still gives access to the same underlying data.
Why is my Go map order random?
The language doesn't define map iteration order, and the runtime varies it on purpose. Sort the keys when order matters.
Why must I assign the result of append?
Because append may allocate a new backing array and return a slice pointing to it. The original slice header is unchanged.
Checklist
- Use
forfor every loop, and rely on per-iteration loop variables in Go 1.22+. - Preallocate slices with
make([]T, 0, n)when you know the size. - Always write
s = append(s, ...). - Cap or clone sub-slices you hand to other code.
- Use
[]T{}instead of a nil slice when the value becomes JSON. - Initialize maps before writing, use comma-ok lookups, and guard shared maps with a mutex.
- Pass pointers to modify values, and return the new slice from functions that append.
Next, read Go strings, bytes and runes for how text fits into this model. If you'd like experienced Go engineers to review or build your backend, Vectorkub does that work.
