Go Language Fundamentals & Syntax

Difficulty

Go has no null. Every type has a well-defined zero value instead.

The zero value table

var i int        // 0
var f float64     // 0.0
var b bool        // false
var s string      // "" (empty string, not nil)
var p *int        // nil
var sl []int      // nil (a nil slice, but len(sl) == 0 works fine)
var m map[string]int  // nil (readable, but writing to it panics)
var ch chan int   // nil

Why this matters in practice

A struct with no explicit initializer is already fully usable:

type Config struct {
    Retries int
    Timeout float64
    Name    string
}

var c Config
fmt.Println(c.Retries, c.Timeout, c.Name) // 0 0 ""

No field is ever an uninitialized garbage value or a null pointer waiting to crash your program. This is one reason Go code has fewer null-check branches than languages where uninitialized means undefined or null.

The one gotcha: nil maps

A nil map behaves like an empty map for reads, but panics on write:

var m map[string]int
fmt.Println(m["x"])   // 0, no panic — reading a missing key is safe
m["x"] = 1            // panic: assignment to entry in nil map

Always initialize a map you intend to write to, with make(map[string]int) or a map literal.

Arrays are rare in everyday Go code. Slices are what you actually use.

The slice header

A slice value is really this, conceptually:

type sliceHeader struct {
    ptr *T
    len int
    cap int
}
arr := [5]int{1, 2, 3, 4, 5}
s := arr[1:3]        // len=2, cap=4 (from index 1 to end of arr)
fmt.Println(s)       // [2 3]
s[0] = 99
fmt.Println(arr)     // [1 99 3 4 5] — s shares arr's backing array

What happens on append

s := make([]int, 2, 2)  // len=2, cap=2
s = append(s, 3)        // cap exceeded: new backing array allocated, data copied

If len(s) < cap(s), append writes into the existing backing array. If not, Go allocates a new array (usually doubling capacity for smaller slices) and copies everything over. This is why appending to a slice sometimes affects a second slice sharing its backing array, and sometimes doesn't — it depends on whether that specific append triggered reallocation.

The classic slicing bug

func trimFirst(s []int) []int {
    return s[1:]   // still shares the same backing array as the caller's slice
}

Sub-slicing never copies. If you need an independent copy, use copy() explicitly:

dst := make([]int, len(src))
copy(dst, src)

Maps look simple, but four specific behaviors trip up almost everyone new to Go.

Checking for existence vs. zero value

m := map[string]int{"a": 1}
v := m["b"]          // 0 — can't tell "missing" from "present with value 0"
v, ok := m["b"]      // v=0, ok=false — this is the idiomatic check
if ok {
    fmt.Println("found:", v)
}

Randomized iteration order

for k, v := range m {
    fmt.Println(k, v)  // order changes between runs, by design
}

Go randomizes map iteration order deliberately, to stop anyone depending on it. If you need sorted output, collect the keys and sort them yourself:

keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)

Concurrent access panics

// Running this with -race, or under real concurrent load, panics:
// "fatal error: concurrent map writes"
go func() { m["x"] = 1 }()
go func() { m["y"] = 2 }()

Maps have zero built-in concurrency safety. Guard shared maps with a sync.Mutex, or use sync.Map for specific high-read, low-write patterns.

No addressable map values

type Point struct{ X, Y int }
m := map[string]Point{"origin": {0, 0}}
m["origin"].X = 5   // compile error: cannot assign to struct field

Map values aren't addressable, since the runtime may relocate them during a rehash. Fix it by reassigning the whole value, or storing pointers (map[string]*Point) instead.

Related Resources

Getting this distinction wrong is the single most common source of "why didn't my change show up" bugs in Go.

Value types copy on assignment

type Point struct{ X, Y int }

p1 := Point{1, 2}
p2 := p1        // full copy
p2.X = 99
fmt.Println(p1.X) // still 1 — p1 was never touched
func modify(arr [3]int) {
    arr[0] = 99   // modifies the local copy only
}
a := [3]int{1, 2, 3}
modify(a)
fmt.Println(a)   // [1 2 3] — unchanged

Reference-like types share underlying data

func modify(s []int) {
    s[0] = 99   // writes through to the shared backing array
}
sl := []int{1, 2, 3}
modify(sl)
fmt.Println(sl)  // [99 2 3] — changed

Table of behavior

TypeCopy behavior on assignment/pass
struct, arrayFull copy of all fields/elements
slice, map, channelHeader/reference copied; underlying data shared
pointerAddress copied; both variables point to the same value
function valueReference to the function; safe to copy and pass around

Opting a struct into reference-like behavior

func modify(p *Point) {
    p.X = 99   // dereferences and writes through the pointer
}
pt := Point{1, 2}
modify(&pt)
fmt.Println(pt.X)  // 99

This is exactly why a method meant to mutate its receiver needs a pointer receiver — a value receiver method only ever sees a copy.

Related Resources

defer is Go's answer to finally blocks, but its exact evaluation timing surprises newcomers.

Basic usage

func readFile(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()   // runs when readFile returns, no matter which path

    // ... use f ...
    return nil
}

LIFO ordering

func demo() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
}
// Output: 3
//         2
//         1

Pitfall: arguments evaluated immediately

func demo() {
    x := 1
    defer fmt.Println("x was:", x)  // "x" captured as 1 right now
    x = 2
}
// Output: "x was: 1", not 2

If you need the final value, defer a closure instead:

defer func() { fmt.Println("x is now:", x) }()  // reads x at call time

Pitfall: defer in a loop

func processFiles(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        defer f.Close()   // BUG: all files stay open until processFiles returns
    }
    return nil
}

With hundreds of files, this can exhaust the OS's file descriptor limit before processFiles ever returns. Fix it by wrapping the loop body in its own function, so each defer fires per iteration:

func processOne(p string) error {
    f, err := os.Open(p)
    if err != nil {
        return err
    }
    defer f.Close()
    // ... use f ...
    return nil
}

Related Resources