There Is No Uninitialized Memory: Variables and Zero Values
Declaring things in Go — var, the short := form, multiple assignment, block scope and the shadowing trap, constants and iota — anchored on the idea that every declared variable starts life at its type's zero value. Compiled and run against Go 1.26.5.
Before you can do anything in a program you have to declare things, and Go’s rules for that are short — deliberately so. But underneath the syntax sits one idea that shapes how Go code reads and how few of a certain class of bugs you’ll write: a declared variable is never uninitialized. The moment a variable exists it holds a well-defined value, its type’s zero value, whether or not you gave it one. That single guarantee is worth understanding before the syntax, because it’s the reason a lot of Go code can skip the defensive checks you might expect.
Everything here is compiled and run against Go 1.26.5.
Zero values: the idea first
In many languages a freshly declared variable is a hazard until you assign to it. A local int might be garbage; an object reference is null until you point it somewhere, and dereferencing it early is the billion-dollar mistake. Go closes that door at the language level. Every type has a zero value, and every declaration that doesn’t specify an initial value gets it:
- numeric types →
0 bool→falsestring→""(the empty string, not null)- pointers, slices, maps, channels, functions, interfaces →
nil - a struct → a struct whose every field is its own zero value
Here is a program that declares one of each and prints it:
package main
import "fmt"
type Point struct {
X, Y int
}
func main() {
var i int
var f float64
var b bool
var s string
var p *int
var sl []int
var m map[string]int
var pt Point
fmt.Printf("int: %d\n", i)
fmt.Printf("float64: %g\n", f)
fmt.Printf("bool: %t\n", b)
fmt.Printf("string: %q\n", s)
fmt.Printf("pointer: %v\n", p)
fmt.Printf("slice: %v (nil? %t)\n", sl, sl == nil)
fmt.Printf("map: %v (nil? %t)\n", m, m == nil)
fmt.Printf("struct: %v\n", pt)
}
$ go run .
int: 0
float64: 0
bool: false
string: ""
pointer: <nil>
slice: [] (nil? true)
map: map[] (nil? true)
struct: {0 0}
Two things there are worth pinning down. First, a zero-valued string is "", a real empty string you can range over and take the length of, not a null you must guard against. Second, a zero-valued struct is fully formed: Point{} is {0 0}, not nil, because each field fell to its own zero. This composability is why Go programmers say the zero value should be useful. A bytes.Buffer needs no constructor because its zero value is a ready, empty buffer; sync.Mutex’s zero value is an unlocked mutex. When you design your own types, aim for the same: make the zero value do something sensible, and callers can skip a construction step.
The nil slice and nil map are the exception that proves the rule. A nil slice reads fine — its length is 0 and you can append to it — but a nil map is read-only; writing to one panics. We’ll get to why when we cover maps. For now, notice the guarantee is real but not a blank check: useful zero value, not always writable.
var and the short form
The long way to declare is var, and it comes in three shapes:
package main
import "fmt"
func main() {
var name string = "Ada" // explicit type
var age = 36 // type inferred from value
var active bool // zero value: false
count := 0 // short form: declare + infer + assign
fmt.Println(name, age, active, count)
// multiple assignment
x, y := 1, 2
x, y = y, x // swap, no temp
fmt.Println(x, y)
}
$ go run .
Ada 36 false 0
2 1
When you supply an initial value, the type is optional — the compiler infers it — so var age = 36 gives an int and var name string = "Ada" states the type you could have left off. When you don’t supply a value, as with var active bool, you must name the type (there’s nothing to infer from), and the variable takes that type’s zero value.
The := short form is what you’ll write most inside functions. It declares and assigns in one move, inferring the type: count := 0 is an int, count := 0.0 a float64. It only works inside a function — package-level declarations must use var — and it requires at least one new variable on the left. That last rule is what lets you reuse := in the n, err := ... pattern you’ll see constantly: as long as one name is new, the others can be reassignments.
Multiple assignment also gives you the clean swap: x, y = y, x. The right-hand side is fully evaluated before anything is assigned, so no temporary is needed and the swap is honest.
Block scope, and the shadowing trap
Go is block-scoped: a variable is visible from its declaration to the end of the enclosing { }. A := inside an inner block creates a new variable that shadows any outer one of the same name for the rest of that block, and this is where := bites.
package main
import "fmt"
func main() {
err := "outer"
fmt.Println("before block:", err)
if true {
err := "inner" // NEW variable, shadows the outer one
fmt.Println("inside block:", err)
}
fmt.Println("after block:", err) // still "outer"
}
$ go run .
before block: outer
inside block: inner
after block: outer
Written on purpose that’s harmless. Written by accident it’s a genuine bug, and the classic version costs people real time. Suppose you mean to accumulate into a total but reach for := inside the loop:
func parseTotal(inputs []string) int {
total := 0
for _, in := range inputs {
n, err := strconv.Atoi(in)
if err == nil {
total := total + n // BUG: := declares a new, block-local total
_ = total
}
}
return total
}
You meant total = total + n (assign) and wrote total := total + n (declare). The inner total is a brand-new variable that lives and dies inside the if; the outer one never moves.
$ go run .
0
Zero, from parseTotal([]string{"1", "2", "3"}), when you expected 6. The fix is one character — = not := — but the compiler won’t save you here, and neither does go vet by default. The lesson is a habit: reach for = when you mean to update an existing variable, and treat a := that names a variable you already have as a thing to look at twice. (Go’s shadow checker exists but is off by default; some teams turn it on in CI.)
Constants and iota
A constant is a value fixed at compile time, declared with const. Constants can be untyped, which makes them flexible in a way we’ll return to in the next chapter; here the mechanical points are that const values can’t be reassigned and must be computable at compile time — no function calls, no reading a variable.
Go has no enum keyword. Instead it gives you iota, a counter that resets to 0 at the start of each const block and increments by one per line. That’s enough to build enumerations:
package main
import "fmt"
const (
StatusPlaced = iota // 0
StatusPaid // 1
StatusShipped // 2
StatusDelivered // 3
)
type ByteSize float64
const (
_ = iota // ignore first value (0)
KB ByteSize = 1 << (10 * iota)
MB
GB
)
func main() {
fmt.Println(StatusPlaced, StatusPaid, StatusShipped, StatusDelivered)
fmt.Printf("KB=%g MB=%g GB=%g\n", KB, MB, GB)
}
$ go run .
0 1 2 3
KB=1024 MB=1.048576e+06 GB=1.073741824e+09
The first block is the everyday case: four named statuses, 0 through 3, no repetition. The second shows the trick that makes iota more than an auto-incrementer. Because the expression on one line is repeated on the blank lines below it, writing 1 << (10 * iota) once defines KB, MB, and GB as successive powers of 1024. The leading _ = discards the 0 value so KB lands on iota 1. You won’t reach for that every day, but it shows iota is a compile-time expression, not just a line number.
One caveat with the simple enum form: because the underlying values are plain integers, an order status of “shipped” and any other constant that happens to equal 2 are indistinguishable to the type system. When you want a real, distinct type — one the compiler will keep separate from a bare int — you give the constants a named type (as ByteSize does above). We’ll build proper typed enums when we reach custom types.
Final thoughts
Declaring things in Go is a small surface: var for the explicit form and package level, := for the short form inside functions, multiple assignment for tuples and swaps, const and iota for compile-time values and enumerations. The idea holding it together is the zero value — every variable is born initialized, so there is no uninitialized memory and no default null waiting to trip you. Design your own types so their zero value is useful and you inherit that same calm. The one sharp edge is := shadowing; when you mean to update, write =. Next we look at what those declared values actually are: Go’s built-in types, and why the language makes you spell out every numeric conversion by hand.
Next: the built-in types, and Go’s refusal to convert numbers for you — why float64(i) is something you’ll type on purpose.
Comments