Variables and Zero Values in Go Explained Properly
Every Go variable starts life with a zero value, and the language leans on that. See what each type starts as and which nil values are safe to use.

A C programmer reading their first Go function usually stops at var count int and asks the right question: what is in count? In C the honest answer is "whatever was in that memory". In Go the answer is always 0, on every platform, in every build, and that small guarantee shapes how the whole language is written. Once you know the rules in this article, a lot of Go code that looks oddly empty starts to make sense. It is also the foundation for the rest of the Go basics cluster.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The rule, and the table that goes with it
When you declare a variable without giving it a value, the compiler gives it the zero value for its type. There is no exception and no way to opt out. The Go specification lists the cases in one short paragraph, and this table is the same list with the ones that surprise people marked.
| Type | Zero value | Safe to use as is? |
|---|---|---|
int, uint, float64, byte, rune | 0 | Yes |
bool | false | Yes |
string | "" (empty, not nil) | Yes |
| Pointer | nil | Comparing yes, dereferencing no |
| Slice | nil | Yes for len, range, append, reading nothing |
| Map | nil | Reading yes, writing panics |
| Channel | nil | Blocks forever on send and receive |
| Function | nil | Calling it panics |
Interface (error, any) | nil | Comparing yes, calling a method panics |
| Array | Every element zeroed | Yes |
| Struct | Every field zeroed | Depends on the fields |
Strings are the one that trips people coming from Java or JavaScript. A Go string is a value type, so it cannot be null. The zero value is the empty string and s == "" is the whole check.
Seeing every zero value at once
Here is the table as a program. I declared one variable of each kind without a value and printed it, so the output below is exactly what the compiler produced.
package main
import "fmt"
func main() {
var (
i int
f float64
b bool
s string
r rune
p *int
sl []int
m map[string]int
fn func()
ch chan int
err error
a any
arr [3]int
)
fmt.Printf("int=%v float64=%v bool=%v string=%q rune=%v\n", i, f, b, s, r)
fmt.Printf("pointer=%v slice=%v nil=%t len=%d\n", p, sl, sl == nil, len(sl))
fmt.Printf("map=%v nil=%t len=%d\n", m, m == nil, len(m))
fmt.Printf("func nil=%t chan nil=%t error=%v any=%v array=%v\n", fn == nil, ch == nil, err, a, arr)
}int=0 float64=0 bool=false string="" rune=0
pointer=<nil> slice=[] nil=true len=0
map=map[] nil=true len=0
func nil=true chan nil=true error=<nil> any=<nil> array=[0 0 0]Two things worth noticing. %q makes the empty string visible, which %v would not, so I reach for it whenever I am debugging a string that might be empty. And the nil slice prints as [], the same as an empty slice would. That is by design, and it is also the reason the two are easy to confuse when you print them.
Structs get the zero value field by field
A struct is zeroed recursively. Every field gets its own type's zero value, including fields that are themselves structs.
package main
import (
"fmt"
"time"
)
type Server struct {
Name string
Port int
Timeout time.Duration
Tags []string
Labels map[string]string
Parent *Server
}
func main() {
var s Server
fmt.Printf("%+v\n", s)
fmt.Printf("%#v\n", s)
}{Name: Port:0 Timeout:0s Tags:[] Labels:map[] Parent:<nil>}
main.Server{Name:"", Port:0, Timeout:0, Tags:[]string(nil), Labels:map[string]string(nil), Parent:(*main.Server)(nil)}The %+v form adds field names, and %#v prints something close to the Go source that would build the value, which is where you can finally see map[string]string(nil). It is the verb I use when a value looks empty and I need to know which kind of empty it is.
The practical consequence: Server{} and var s Server are the same value. Writing s := Server{} instead of var s Server is a style choice, not a semantic one. The community leans on var for "this starts empty and I will fill it in" and on := with a literal when there is data in the braces.
Which nil values you can use without checking
Zero values are only useful if the zero value does something sensible. For three of the common nil types it does, and for the rest you need to know the rule.
A nil slice is a first-class citizen. You can take its length, range over it, slice it and append to it, and append will allocate a backing array for you the first time. This is why Go code almost never initializes a slice before filling it:
package main
import "fmt"
func main() {
var names []string
fmt.Println(len(names), cap(names), names == nil)
for range names {
fmt.Println("never runs")
}
names = append(names, "ada", "grace")
fmt.Println(len(names), names == nil, names)
}0 0 true
2 false [ada grace]A nil map can be read. A lookup returns the zero value of the element type, and len returns 0. Writing is the problem. The assignment m["k"] = 1 on a nil map panics at runtime with assignment to entry in nil map, which is common enough that it has its own article: the nil map panic and how to fix it. The fix is always to create the map with make or a literal before the first write.
A nil pointer compares fine and dereferences badly. The specific failure, invalid memory address or nil pointer dereference, is the most common panic in Go. Methods on a nil pointer are legal as long as they do not touch the fields, and some standard library code relies on it.
An interface is nil only when both its type and its value are unset. That detail is where the nil interface trap lives, and it is worth reading before you return a typed nil pointer as an error.
Channels deserve one sentence of warning. Sending to or receiving from a nil channel blocks forever, which is a bug in a normal program and a useful trick inside a select.
Why the language is built around this
Go's designers leaned on zero values to remove a whole category of constructor ceremony, and Effective Go says so directly. The standard library is the best evidence, because several of its most used types need no setup at all:
package main
import (
"bytes"
"fmt"
"strings"
"sync"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
var buf bytes.Buffer
var sb strings.Builder
total := 0
for i := range 4 {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
total += i
mu.Unlock()
}()
}
wg.Wait()
buf.WriteString("total=")
fmt.Fprintf(&buf, "%d", total)
sb.WriteString(buf.String())
sb.WriteString(" ok")
fmt.Println(sb.String())
}total=6 okNone of those four variables was constructed. A sync.Mutex starts unlocked, a sync.WaitGroup starts at zero, a bytes.Buffer starts empty and grows when you write to it, and a strings.Builder does the same. This is the design goal you will see called "make the zero value useful", and when you design your own types you should aim for it as well. I wrote more about that in designing types around a useful zero value.
The rule has one cost. Because Port int starts at 0, you cannot tell "the caller set the port to zero" from "the caller never set it". When that distinction matters, the usual answers are a pointer field (*int, where nil means unset), a separate Set boolean, or a constructor that applies defaults. Each costs something, and most config structs live fine with the plain field and a documented default.
Declaring with var, with :=, and with new
There are three ways to bring a variable into existence, and the zero value story is the same in all of them.
var x T declares x with the zero value of T. It works at package level and inside functions.
x := expr declares and initializes in one step, and only works inside functions. It never gives you a zero value on its own; it gives you whatever expr is. That includes x := 0, which is explicit about the value even though it matches the zero value.
p := new(T) allocates a zeroed T and returns a pointer to it. For a struct it is the same as &T{}, and the second form is far more common because you can fill in fields.
One habit I would suggest: when the zero value is exactly what you want, use var. It tells the reader "nothing special here". When you are setting something non-zero, use :=. The distinction is small, but it makes var lines scan as "this will be filled in later".
Package-level variables follow the same rule
A package-level var counter int is zero before main starts, and package initialization runs in dependency order before any init function. If you write var cache map[string]string at the top of a file, you have a nil map, and the first goroutine to write to it will panic. Declare it with make, or guard it behind a function that builds it once. I treat any package-level map with suspicion for this reason, and for the race it invites.
Where the zero value is the wrong default
Zero values are a convention that works because most types have an obvious empty state. They fall short in a few places, and it is better to know them than to be surprised.
A time.Time zero value is January 1, year 1, which is a valid date and not an "unset" marker. Use t.IsZero() to test for it, and do not compare against a literal date. A zero time.Duration is zero nanoseconds, which many APIs read as "no timeout" and others read as "time out now". Check the documentation for each one rather than assuming.
A struct with unexported state, such as a type that wraps a connection, often cannot offer a useful zero value. The convention there is a constructor named New or NewThing, and a doc comment that says the zero value is not ready. If a user can call methods on the zero value and get a nil pointer panic, that is a design smell worth fixing in the type, not in the docs.
Finally, if you copy the pieces of an API from another language, be careful with "nullable" fields. Go does not have them. A string that must distinguish "missing" from "empty" needs a pointer or a wrapper type, and JSON decoding makes that choice visible quickly. If you are going to model optional fields from JSON, the JSON to Go struct tool shows what a generated struct looks like, and the pointer trade-off is still yours to make.


