---
title: 'Zero Values'
source: 'https://academia.sh/en/courses/go-fundamentals/zero-values'
course: 'Go Fundamentals'
language: en
updated: '2026-09-13T05:08:45+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds and runs a single frame that sorts fifteen unwritten cases into defined, rejected, and open'
    - "Measures what a type's zero value is and that nil carries four separate meanings across four separate types"
    - 'Shows, by comparing a nil slice against a nil map, that a defined zero value is not always usable'
---

# Zero Values

Fifteen unwritten cases are run through a single frame: eight find a defined counterpart from the language, four are rejected by the compiler, three are left open to runtime. Limiting measurement: nil can be appended to a slice but cannot be written to a nil map — both are the same kind of zero value.

The previous lesson ended without asking what would happen if we wrote `var b int8` and never
gave it a value. That question is the real one this course has been deferring since its
opening: when a variable is left without an initial value, what does the language find there?
This lesson asks that question not in one example but in fifteen separate places, and sorts
each into one of three classes — **defined**, the language puts something in place of the
unwritten; **rejected**, the compiler will not accept the unwritten state at all; **open**, the
result is left to runtime. The numbers referred to throughout the rest of the course are
produced here.

The Programming Foundations course (M01/K02) built binding a variable to a name using Python;
there, a name could not exist without being given a value. **That concept is not rebuilt here**
— one sentence is enough to state the difference: in Go a variable can exist without ever being
given a value, because the type system has already assigned every type a **zero value** in
advance. What this lesson measures is what that zero value is for each type, and whether it is
really "there" in every situation.

## The Premise

The single most useful question when reading a Go program is not what is written but what is
not. Every variable left without an initial value has a defined value; an unused variable stops
compilation; a nonexistent key returns two values at once; a variadic parameter given no
argument at all becomes an empty slice too. In the same language, some unwritten things have no
counterpart at all, and the program stops while running.

The measurement reduces this triple to a single question: for each unwritten case, does the
language put something in place, does the compiler reject it, or is the result left open? The
second half of finding the answer calls the compiler **from inside the program**: a piece of
source is written into a temporary package, the compiler is run, and only whether it passed is
read — the error text is never read, because what is being measured is not "why was it
rejected" but "was it rejected."

## The Core: An Observer That Sorts Into Three Classes

```go
// unwritten.go — what does the unwritten thing become: does the language put something defined, does the compiler reject it, or is it left open
package main

import (
	"fmt"
	"os"
	"os/exec"
	"path/filepath"
)

var defined, rejected, open int

func observe(name, class, evidence string) {
	switch class {
	case "defined":
		defined++
	case "rejected":
		rejected++
	default:
		open++
	}
	fmt.Printf("%-42s %-11s %s\n", name, class, evidence)
}

func summarize() {
	fmt.Printf("unwritten: %d defined, %d rejected, %d open\n", defined, rejected, open)
}

func compiles(source string) bool {
	dir, err := os.MkdirTemp("", "trial")
	if err != nil {
		return false
	}
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module trial\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "code.go"), []byte(source), 0o644)
	cmd := exec.Command("go", "build", "./...")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	return cmd.Run() == nil
}

func panics(f func()) (p bool) {
	defer func() {
		if recover() != nil {
			p = true
		}
	}()
	f()
	return false
}
```

**LB13.** `observe` is the single entry point that writes every observation to a class; the
three package-level variables holding the counters (`defined`, `rejected`, `open`) only
increase here, so the totals stay consistent with the observations whenever `summarize` is
called.

**LB14.** `panics` runs a function and catches with `recover` whether it panicked, returning
only `panic=true`/`panic=false` — no stack trace is ever printed. This is the single method
used everywhere in the course that measures a panic.

These two helpers give a tool to each of the three classes this lesson measures: `observe` and
`summarize` hold the classification and the total, `panics` catches the open-ended behavior,
`compiles` (defined in the previous lesson's section) measures the rejection. Together, the
three reduce the body of a fifteen-line measurement to a single call shape: each line gives
`observe` a name, a class, and evidence, and everything else is handled by these three helpers.

## Fifteen Unwritten Cases

```go
// measurement.go — fifteen unwritten cases, split into three classes
package main

import "fmt"

type Record struct {
	Name   string
	Number int
	Active bool
}

func namedReturn() (result int, err error) { return }

func variadic(numbers ...int) []int { return numbers }

func main() {
	fmt.Println("-- what stands in for the unwritten --")

	var integer int
	observe("an integer with no initial value", "defined", fmt.Sprint(integer))

	var slice []int
	observe("a slice with no initial value", "defined",
		fmt.Sprint("nil=", slice == nil, " length=", len(slice), " appendable=", append(slice, 1)))

	var m map[string]int
	observe("reading a map with no initial value", "defined", fmt.Sprint(m["missing"]))

	writePanicked := panics(func() { m["new"] = 1 })
	observe("writing to a map with no initial value", "open", fmt.Sprint("panic=", writePanicked))

	var pointer *Record
	readPanicked := panics(func() { _ = pointer.Number })
	observe("reading a pointer with no initial value", "open", fmt.Sprint("panic=", readPanicked))

	var record Record
	observe("unwritten struct fields", "defined", fmt.Sprintf("%q %d %t", record.Name, record.Number, record.Active))

	s, e := namedReturn()
	observe("an unwritten named return", "defined", fmt.Sprint(s, " ", e == nil))

	observe("a variadic parameter with no argument", "defined",
		fmt.Sprint("nil=", variadic() == nil, " length=", len(variadic())))

	source := []int{10, 20, 30, 40}
	observe("unwritten slice bounds", "defined", fmt.Sprint(source[:], " ", source[1:], " ", source[:2]))

	value, ok := m["missing"]
	observe("a nonexistent key returning two values", "defined", fmt.Sprint(value, " ", ok))

	text := "élan"
	observe("a string's byte instead of a character", "open",
		fmt.Sprint("length=", len(text), " first=", text[0], " runes=", len([]rune(text))))

	observe("unused variable", classifySource(`package trial
func F() { x := 1 }`), "")

	observe("unused import", classifySource(`package trial
import "fmt"
func F() {}`), "")

	observe("unwritten return value", classifySource(`package trial
func F() int { }`), "")

	observe("implicit type conversion", classifySource(`package trial
func F() { var a int32 = 1; var b int64 = a; _ = b }`), "")

	summarize()
}

func classifySource(source string) string {
	if compiles(source) {
		return "open"
	}
	return "rejected"
}
```

```
-- what stands in for the unwritten --
an integer with no initial value           defined     0
a slice with no initial value              defined     nil=true length=0 appendable=[1]
reading a map with no initial value        defined     0
writing to a map with no initial value     open        panic=true
reading a pointer with no initial value    open        panic=true
unwritten struct fields                    defined     "" 0 false
an unwritten named return                  defined     0 true
a variadic parameter with no argument      defined     nil=true length=0
unwritten slice bounds                     defined     [10 20 30 40] [20 30 40] [10 20]
a nonexistent key returning two values     defined     0 false
a string's byte instead of a character     open        length=5 first=195 runes=4
unused variable                            rejected
unused import                              rejected
unwritten return value                     rejected
implicit type conversion                   rejected
unwritten: 8 defined, 4 rejected, 3 open
```

The last four lines are the four rejections measured in detail in the first lesson; here they
sit as the completion of the list and fall into the same class (`rejected`). The remaining
eleven lines belong to this lesson and cover eight separate types: integer, slice, map,
pointer, struct, named return value, variadic parameter, string. Each line asks the same
question of a single type — what happens when it is left with no initial value — and delivers
the answer to `observe` in three parts: a name, a class, evidence. The evidence column carries
the real values that back up the claim; the next section reads these one by one.

## Reading the Numbers

**First claim: there is no such thing as "uninitialized" in Go.** In **eight** of the fifteen
unwritten cases, the language puts something defined in place of the unwritten: an integer's
zero value is `0`, a slice's zero value is `nil` but its length is `0` and it is still
appendable, reading from a map returns `0`, a struct's fields carry their own zero values, an
unwritten named return returns `0` and `nil`, a variadic parameter given no argument becomes a
`nil` slice, unwritten slice bounds stretch from the start to the end, a nonexistent key
returns `0` and `false`. All eight are not a convenience but a contract: the reader knows what
they will find without looking at the source.

What these eight share is that the zero value looks different **per type** but is always
predictable. For an integer the zero value is numeric zero; for a string it is the empty
string (`""`); for a boolean it is `false`; for a pointer, a slice, a map, or a function it is
`nil`. All four are called by the same word (`nil`) without carrying the same idea, but all
four carry the same intent: "not yet bound to anything." `nil` itself is not a single value —
a pointer's `nil` means it points to no address in memory, a slice's `nil` means it has no
backing array, a map's `nil` means no storage has been allocated, a function's `nil` means
there is no code that can be called. All four print with the same word, but all four represent
a distinct absence within their own type.

**Second claim: all four things the compiler rejects are, in other languages, a warning level
issue.** Unused variable, unused import, an unwritten return value, implicit type conversion —
all four make the source fail to compile. Rejecting is not a show of strictness, it is a
decision to **narrow** what stands in for the unwritten: the language chooses, in these four
cases, "never allow it" over "put something defined there."

Compared with the first eight, these four show an interesting reversal. In the first eight, the
language lets the source run despite something unwritten, and fills the gap itself. In these
four, instead of filling the gap, the language never lets the source run at all. Both are "what
stands in for the unwritten," but one is a value carried into runtime, the other is a door
closed at compile time.

**Third claim: definedness works in the reading direction and stops in the writing and
interpreting direction.** All three of the open cases show this. **Reading** from a map with no
initial value is defined, but **writing** to it stops the program — a panic. A pointer with no
initial value is a defined value (`nil`), but **reading** it stops the program — again a
panic. A string's length is defined, but what indexing into it gives is not a character, it is
a **byte** — `"élan"` is five bytes, four runes, and the first byte is `195` (the first part of
a multi-byte encoding, not a letter on its own).

## Limiting Measurement: "Defined" Does Not Mean Usable

Not all eight defined lines are equally usable. Comparing the second line (`nil` slice) with
the third line (reading from a `nil` map) makes the difference clear: both are left with no
initial value, and both are `nil`. But `append` works on a `nil` slice — the output shows
`appendable=[1]` — while writing to a `nil` map panics. The same word (`nil`), the same class
(`defined`), two different results.

**LB15.** The difference is not in `nil` itself, it is in the **separate contract** the
language gives each of these two types. A slice, even when `nil`, is a header that carries a
length (`0`) and a capacity (`0`); `append` can allocate a new backing array for that header and
grow, because growing is a behavior a slice already has. A map, when `nil`, contains no storage
at all; reading can cope with that emptiness (the key being looked for does not exist at all,
so its zero value is returned), but writing requires allocating room for a new entry, and a
`nil` map has nowhere to allocate it. The word "defined" is correct in both, but **what** is
defined changes from type to type.

This is a warning worth keeping in mind while reading the eight defined lines: a value having
a zero value does not mean that value can withstand **every** operation. Which operations work
with a zero value will be measured again when this course's next topic treats slice and map
types on their own.

The same limiting reading appears once more in the second open line. A pointer's zero value is
also `nil`, and like a `nil` slice, it withstands some operations — comparing a `nil` pointer
against another pointer, or reassigning it, works without a problem. But reading a field
(`pointer.Number`) does not work, because reading a field first requires a struct instance to
exist, and a `nil` pointer points to no instance at all. Three types — slice, map, pointer —
all three carry `nil` as their zero value, but all three define their own set of behaviors;
there is no single "`nil` rule," each type carries its own separate contract.

## Blind Spot: The Rejection Reason Is Not Measured

The four rejection lines rely on `compiles`'s `true`/`false` answer, and the error text is
never read. This has a cost: a source could have been rejected for a reason **other** than the
one being measured, and the measurement cannot tell the difference. This is why the rule holds
— every rejected piece of source must be compilable if not for exactly one unwritten thing; in
the unused-variable example there is nothing wrong besides `x := 1`, in the return-value
example there is nothing wrong besides `func F() int { }`. This condition was set up and
verified by hand; the measurement tool does not check it on its own.

**LB16.** This is a deliberate limitation coming from the measurement's method, not an
oversight. `compiles` only reads `cmd.Run()`'s exit status; not reading the error text protects
both the ban on version-dependent data and the "what, not why" question. The cost is that the
four sources in this lesson had to be verified **by hand** — each was written by trying exactly
one change and checking that everything else stayed fixed. The same discipline continues
everywhere in the rest of the course wherever a compiler rejection is measured.

The eight-line defined group has a similar blind spot: the `observe` function prints the
evidence it is given as is, it does not test its correctness. Whether a line truly belongs to
the "defined" class is decided by hand while the code is being written — the frame only makes
that decision **countable**, it does not produce it itself. The oracle — the knowledge of which
unwritten case falls into which class — sits outside the program, in the hands of whoever wrote
this lesson.

## Summary

- Of fifteen unwritten cases, **8 are defined, 4 are rejected, 3 are left open** — the course's
  binding split was produced here.
- There is no such thing as "uninitialized" in Go: each of the eight defined lines carries a
  concrete value the language puts in place of the unwritten.
- The four things the compiler rejects stop compilation by a decision to narrow what stands in
  for the unwritten, even though all four are harmless at runtime.
- Definedness works in the reading direction and stops in the writing and interpreting
  direction: reading from a `nil` map is defined, writing panics; a string's length is defined,
  indexing gives a byte.
- A `nil` slice and a `nil` map carry the same word but do not withstand the same operations —
  a `nil` slice can be appended to, a `nil` map cannot be written to.

## Next Step

Zero values measured what happens when a variable is **never touched at all**. But a program
almost never stays untouched — it branches, it loops, it moves from one state to another. The
next lesson turns to control flow: why can `for` be written without a condition, why can
`switch` be written without an expression, and what stands in for the `break` left unwritten at
the end of a branch?
