all_lessons/Go/02 · Values & the zero valuelesson 3 / 18

Values, types, and the zero value

Two questions decide whether a piece of code is safe to change: where does this data live, and what happens when I copy it? Most languages answer them implicitly and differently for every type, so the reader has to memorize a table of exceptions. Go's answer is uniform and visible: a variable is its value, assignment copies that value, and every type has a usable zero value you get for free. This lesson installs the memory model the rest of the series stands on — and shows the one place the rule has a sharp edge, the slice and map headers, where "copy" means something you must see to use correctly.

The thesis, here
Go optimizes for the reader, and the reader's hardest job is tracking aliasing — which names secretly point at the same data, so that a write through one is a surprise through another. Go's bargain, applied to memory: pay the cost of an occasional explicit copy and an occasional explicit pointer, and in exchange the default — plain assignment, plain argument passing — never silently shares mutable state. The complexity Go refuses here is the invisible reference. What it buys is that you can read b := a and know, almost always, that b is a fresh, independent value.
Linear position
Prerequisite: Lesson 01 — you've seen that Go's whole posture is deliberate subtraction in service of programming in the large. New capability: read any Go variable and answer "is this a copy or a share?"; name the memory layout of a struct, array, slice, and map; and explain why "make the zero value useful" is a design rule, not a coincidence. This is the foundation Lessons 03 (pointers/heap/GC) and 04 (value vs pointer receivers) are built directly on top of.
The plan
Four moves. (1) Why value semantics — what aliasing costs the reader and how "a variable is its value" removes it. (2) The type ladder: scalars, structs, and arrays as flat value types with a known size. (3) The two famous exceptions — slices and maps — as small headers that copy cheaply but point at shared backing storage. (4) The zero value as a deliberate design rule, plus == and what it really compares.

1 · The problem: aliasing is the reader's tax

Consider a line as ordinary as b = a. In Java or Python, if a holds an object, b now refers to the same object: mutating b.x changes a.x. If a holds an int, b is an independent copy. The same syntax means two opposite things depending on the type — and the reader has to know which category every type falls into to predict whether a later write will spook a value three functions away. That invisible question — "who else has a handle on this?" — is aliasing, and it is the single largest source of "I changed this and something unrelated broke" bugs. It is also the reader's tax: nothing in the syntax tells you the answer, so you carry the table in your head.

Go's choice is to make assignment uniform and copying. A variable is not a handle to a value living elsewhere; the variable is the value, sitting in that variable's own storage. b := a copies the bits of a into b. Passing an argument copies it into the parameter. Returning copies it out. There is exactly one rule, it is the same for every type, and it means the default behavior never silently shares mutable state. When you do want sharing, you say so with a pointer (&a) — and now the share is visible in the code and in the type (Lesson 03). This is question 2 of the five — who owns this data, and how does it move? — answered structurally instead of by convention.

reference-by-default (Java/Python objects)
b = a may copy or may alias depending on the type. The reader must know each type's category; mutation can leak across names invisibly. Expressive, but the aliasing graph is implicit.
value-by-default (Go)
b := a always copies the value. Sharing exists only where you wrote a pointer. The aliasing graph is in the source — a read of the code tells you who can mutate what.
what the bargain bought
You pay for some copies (a struct copy is a few words moved) and the occasional explicit */&. You buy a default that is safe to reason about — the cost paid by the writer, the safety banked by every later reader.

2 · The value ladder: scalars, structs, arrays

Start with the types that are nothing but their bits. The booleans (bool), the sized integers (int8int64, their unsigned siblings, and int/uint which are 64-bit on a 64-bit platform), the floats (float32/float64), and the rest are pure scalars: a variable of that type holds the number, full stop. There are no implicit conversions between them — you cannot add an int32 to an int64 without an explicit int64(x) — which is half (1) of the bargain again: the conversion that "saves typing" is exactly the one that hides a truncation bug from the reader, so Go withholds it.

A struct is the next rung: a fixed set of named fields laid out contiguously in memory, in declaration order (with the compiler inserting alignment padding). A struct value is flat — it contains its fields directly, not pointers to them — so a struct is exactly as "a value" as an int is. Copying a struct copies every field.

type Point struct {
    X, Y int     // two 8-byte ints, contiguous
}

a := Point{1, 2}
b := a           // COPY: b is an independent Point{1, 2}
b.X = 99
// a is still {1, 2} — no aliasing, because Point is a value

An array[N]T, with the length baked into the type — is also a flat value: [3]int is three ints in a row, 24 bytes on a 64-bit machine, and copying it copies all three. This is the surprise for newcomers from other languages: passing a [1000]int to a function copies 8 KB. Arrays are values, and values copy. That cost is exactly why arrays are rarely used directly and the slice exists — which is the next section.

The flat-value cost is real, and it's the point
Copying a big struct or array isn't free — it's O(size) bytes moved (a memmove the compiler emits). Go doesn't hide this; it makes it visible so you choose a pointer when the copy is too big (Lesson 03) or when you want the callee to mutate the original (Lesson 04). The C++ track teaches the same copy cost from the machine up; here it falls out of one uniform rule.

3 · The two exceptions you must see: slices and maps

An array's size is fixed at compile time, but most real data has a length you don't know until runtime. The slice is Go's growable view onto a backing array — and it is the most important value in the language to understand precisely, because it bends the value-semantics rule in a way that bites people who don't know its shape.

A slice value is not the elements. It is a small, fixed-size header — three words, 24 bytes on a 64-bit platform — holding a pointer to a backing array, a length, and a capacity:

slice header (24 bytes on 64-bit) backing array (shared, on the heap) +-----------+-------+-------+ +----+----+----+----+----+ | ptr ---|--> | | ------> | 10 | 20 | 30 | ?? | ?? | +-----------+-------+-------+ +----+----+----+----+----+ | len = 3 | cap=5 | 0 1 2 (3) (4) +-----------+-------+

Now the rule is consistent and surprising at once. When you copy a slice — s2 := s1, or pass a slice to a function — you copy the 24-byte header. That copy is cheap and obeys value semantics. But both headers now hold the same pointer, so they share the same backing array. Writing s2[0] = 99 is visible through s1. The slice is a value; what it points at is shared. That is the one place where "Go copies" and "but my change leaked" are both true — and once you can see the header, it stops being a mystery.

s1 := []int{10, 20, 30}
s2 := s1            // copies the HEADER; same backing array
s2[0] = 99          // s1[0] is now 99 too — shared backing

// append is where capacity matters:
s3 := s1[:2]        // len 2, cap 3, same array
s3 = append(s3, 77) // fits in cap → OVERWRITES s1[2] in place!
// now s1 == [99 20 77]

s4 := []int{1, 2, 3}
s4 = append(s4, 4)  // exceeds cap → allocates a NEW array, copies, s4 detaches

That append behavior — mutating in place when capacity allows, reallocating (and copying) when it doesn't — is the slice's whole trade-off in one operation. Growth is amortized O(1) because the runtime roughly doubles capacity on each reallocation, but the moment it reallocates, your slice quietly stops aliasing the old backing array. Tracking that is question 2 (how does the data move?) at its sharpest.

A mapmap[K]V, Go's hash table — is the same idea taken further: a map value is essentially a pointer to a runtime hash-table structure. Copying a map variable copies that pointer; two map variables share one table, so a write through one is seen through the other. Lookups, inserts, and deletes are amortized O(1). Two consequences the reader must internalize: a map is always a shared handle (never a deep copy on assignment), and its zero value, nil, is read-safe but write-unsafe — reading a nil map returns the value type's zero, but writing to one panics. You must make(map[K]V) before the first write. This is the single exception to "the zero value is always usable," which §4 takes head-on.

TypeWhat the variable holdsWhat a copy/assignment doesZero value
int, bool, float64the value itselfindependent copy0, false, 0.0
string2-word header (ptr + len), bytes immutablecopies header; bytes can't change, so safe"" (len 0)
structall fields, contiguous & flatcopies every fieldevery field's zero
[N]T arrayN elements, contiguous & flatcopies all N elementsarray of T's zeros
[]T slice3-word header → shared backing arraycopies header; shares backingnil (len 0, usable: append/range OK)
map[K]Vpointer to shared hash tablecopies pointer; shares tablenil (read OK, write panics)
*T pointeran addresscopies the address; shares the pointeenil

Read the table as one sentence: everything copies its top-level value; the value is sometimes a header or pointer that shares what it points at, and Go makes you write the [], map, or * so the sharing is never invisible. A string sits comfortably in the "shares but safe" zone: its bytes are immutable, so sharing them can never surprise a reader — which is why string copies are cheap (2 words) and string is treated as a plain value.

4 · The zero value is a design rule, not luck

In many languages, a freshly declared variable is in an undefined or null state until you initialize it, and using it before then is a bug (C's garbage, Java's NullPointerException, "use before assignment" errors). That is complexity pushed onto the reader: every variable carries an invisible "is it ready yet?" flag you must track. Go's rule removes the flag. Every variable is memory, and that memory is always set to the type's zero value0 for numbers, false for bools, "" for strings, nil for pointers/slices/maps/channels/interfaces/functions, and the recursively-zeroed version for structs and arrays. There is no "uninitialized." var x int is 0, guaranteed, every time.

That guarantee is cheap (the allocator zeroes memory anyway) but it only pays off if the zero value is useful — and so "make the zero value useful" became an explicit Go design proverb. The standard library is built around it: a var buf bytes.Buffer is an empty buffer ready to write into, no constructor needed; a var mu sync.Mutex is an unlocked, ready-to-use mutex; a var wg sync.WaitGroup is ready to count. The payoff for the reader is enormous: a struct full of these types needs no constructor and no "did someone forget to initialize this?" anxiety. The type designer pays the cost of arranging that the all-zero state is a valid state; every user banks it.

type Counter struct {
    mu    sync.Mutex          // zero value: unlocked, usable
    total int                 // zero value: 0
    seen  map[string]bool     // zero value: nil — NOT write-usable!
}

var c Counter                 // fully usable EXCEPT c.seen
c.mu.Lock()                   // fine, zero mutex works
c.total++                     // fine
c.seen["x"] = true            // PANIC: assignment to entry in nil map

The map field is the cautionary asterisk: because a nil map can't be written, a struct with a map field is not fully usable at its zero value, and a careful designer either documents that you must construct it (a NewCounter that does seen: make(map[string]bool)) or lazily allocates on first write. Knowing exactly which types violate "the zero value is usable" — really just maps (write) and nil pointers (deref) — is what separates someone who has read about Go from someone who can write it.

5 · Equality: what == actually compares

Because values are flat, == can be defined structurally and predictably. For scalars it's the obvious numeric/boolean comparison. For strings it compares the bytes. For structs and arrays, == compares field by field / element by element — two structs are equal when every corresponding field is equal, computed at compile-time-known size, with no user-defined equality to hide surprises (no operator overloading — half (1) of the bargain again). Pointers are equal when they hold the same address.

The deliberate gaps tell you something. Slices and maps are not comparable with == (except against nil) — and that's a design choice, not an oversight: it's genuinely ambiguous whether two slices should be "equal" when their headers differ but their elements match, so Go refuses to guess and sends you to slices.Equal / maps.Equal where you state your intent. The same flat-comparability rule is why a struct can be a map key only if all its fields are comparable, and why putting a slice field in a struct silently makes that struct un-usable as a map key.

Checkpoint exercise

Try it
Predict, then run, this program — and explain each result using the header model from §3:
func main() {
    s := []int{1, 2, 3, 4}
    t := s[:2]            // (1) what are len(t), cap(t)?
    t = append(t, 99)     // (2) what is s now, and why?
    t = append(t, 100, 101, 102) // (3) after this, does t still alias s?

    var m map[string]int
    fmt.Println(m["k"])   // (4) does this panic or print something?
    m["k"] = 1            // (5) what happens here?
}
Then take a struct you've designed (in any language) and ask question 2 of the five — who owns this data and how does it move? — of each field: is it a value the holder owns, or a handle onto something shared? Mark the fields whose sharing is invisible in the type. That invisibility is exactly the tax Go's value semantics removes.

Where this points next

We've established that a Go variable is its value and that sharing happens only through a header or a pointer you can see in the source — but we've waved at "pointer" and "the backing array lives on the heap" without saying who decides where a value lives or what cleans it up. Lesson 03 answers exactly that: pointers without arithmetic (& and *), the stack-vs-heap question that the compiler's escape analysis settles for you (it is not new vs & that decides), and the garbage collector as the bounded runtime cost Go chose to pay — the concurrent tri-color mark-sweep with sub-millisecond pauses that makes "just share it with a pointer" safe. Everything in this lesson about who shares what becomes, in 03, where it lives and who frees it.

Takeaway
Go gives memory one uniform rule so the reader never has to carry an exception table: a variable is its value, and assignment, argument passing, and return all copy that value — so the default never silently shares mutable state, and sharing exists only where you wrote a pointer, a slice, or a map. Scalars, structs, and fixed arrays are flat values that copy in full (a real O(size) cost Go makes visible so you can choose a pointer instead). Slices and maps are the studied exceptions: a slice is a 24-byte three-word header (ptr/len/cap) and a map is a pointer to a hash table, so copying them is cheap but shares the backing storage — and append aliases in place until it reallocates. The zero value of every type is defined and the language is designed so it's usable (an empty bytes.Buffer, an unlocked sync.Mutex), with the one asterisk that a nil map is read-safe but write-panics. The transferable habit: read every variable as an answer to "copy or share?", and treat invisible aliasing as a cost the next reader pays — which is question 2 of the five, made structural.

Interview prompts