Module 1 · Lesson 1 · ~25 min read
Every Go variable has a useful default without you writing a constructor. nil is not one concept — it's the zero value for several type families, and they behave differently. Get these two ideas right and half of Go's "weirdness" disappears.
In Python, this is a NameError:
print(x) # NameError: name 'x' is not defined
In JavaScript, it's either undefined or a ReferenceError, depending on whether the identifier was declared. Either way the concept of "uninitialized" exists at runtime.
In Go, uninitialized does not exist as a runtime state. Every declaration is immediately set to its type's zero value. The compiler won't let you have an unbound variable.
var n int // n == 0
var s string // s == "" (empty string, not nil)
var b bool // b == false
var p *User // p == nil
var xs []int // xs == nil, but len(xs) == 0 and you can range over it
var m map[string]int // m == nil, reading is fine, WRITING panics
This is a language-level design choice, not a style choice. It means a lot of defensive initialization code you'd write in Python or JS is unnecessary in Go.
| Type | Zero value | Can you use it without more init? |
|---|---|---|
int, int32, int64, uint* | 0 | Yes |
float32, float64 | 0.0 | Yes |
bool | false | Yes |
string | "" | Yes |
Pointer *T | nil | No — dereferencing panics |
Function func(...) | nil | No — calling panics |
Interface (any interface{...}) | nil | No — calling a method panics |
Slice []T | nil | Yes — len/cap/range/append all work. Reading panics only for indexing. |
Map map[K]V | nil | Partially. Reading fine, writing panics. |
Channel chan T | nil | No — send/receive blocks forever, close panics |
Struct struct {...} | all fields zero-valued recursively | Usually yes — this is idiomatic |
Array [N]T | all elements zero-valued | Yes |
This is the #1 gotcha for newcomers. append on a nil slice just works — it allocates as needed. Writing to a nil map panics. Why the difference? A slice header (pointer, len, cap) is a value — append returns a new one. A map is always a reference to an opaque hash structure; there's no way to "return" a new map from an assignment.
var xs []int // nil slice
xs = append(xs, 1) // fine: len(xs) == 1
var m map[string]int // nil map
m["x"] = 1 // PANIC: assignment to entry in nil map
var _ = m["x"] // fine — reads return zero value (0 here)
Python has None, a singleton instance of NoneType. JavaScript has null and undefined. Hack has null. In all these languages, the null-like value is (roughly) uniform — one kind of "nothing."
In Go, nil is the zero value of six distinct type families: pointers, interfaces, slices, maps, channels, and functions. Each family's nil is a different runtime representation:
You can't compare a nil of one type family to nil of another without conversion. You can only assign nil to a variable of a nilable type.
var x int = nil // COMPILE ERROR: cannot use nil as int value
This one will bite you. Read it twice:
type MyErr struct{}
func (*MyErr) Error() string { return "oops" }
func doThing() error {
var err *MyErr // err is (*MyErr)(nil)
return err // returned as an error interface
}
func main() {
err := doThing()
if err != nil { // TRUE! and now we're hurting
fmt.Println(err.Error()) // panic — *MyErr is nil so Error dereferences nil
}
}
The interface value is not nil, because its type descriptor is set to *MyErr. Only its data pointer is nil. An interface equals nil only when both type descriptor and data pointer are zero.
Never return a typed nil pointer as an interface. If a function returns error, write return nil (a literal nil that becomes a nil interface). Don't return var e *MyErr; return e — it'll look non-nil to callers.
This trap is the single most common nil-related bug in production Go code. The Canton codebase, any gRPC Go client, any HTTP handler — all rely on this pattern being handled correctly.
var m map[string]int
fmt.Println(m["missing"], len(m))
type Config struct {
Timeout time.Duration
Retries int
Hosts []string
Labels map[string]string
}
var c Config
c.Hosts = append(c.Hosts, "a")
c.Labels["env"] = "prod"
make(), or a constructor function.null, empty → []) and explicit equality checks against nil. Effective Go actively prefers var xs []int for "empty-so-far" slices.Go's designers made three interlocking choices:
These are not neutral choices. They're trade-offs against expressiveness. Go is explicit and occasionally verbose because the designers want code to be readable by someone who didn't write it — and who may be reviewing it under time pressure.
A Canton Go client will be part of critical financial infrastructure. Someone will read your code at 2 AM when a settlement is stuck. Go's boring, explicit style pays off in exactly that context. Don't fight it.
type Buffer struct {
data []byte
}
func (b *Buffer) Write(p []byte) (int, error) {
b.data = append(b.data, p...)
return len(p), nil
}
// No constructor needed:
var buf Buffer
buf.Write([]byte("hi")) // works — b.data starts nil, append handles it
This is why bytes.Buffer, sync.Mutex, sync.WaitGroup, and many other standard library types don't have constructors — the zero value is the valid empty state.
type Cache struct {
m map[string]string
mu sync.Mutex
}
func NewCache() *Cache {
return &Cache{m: make(map[string]string)}
}
Convention: name the constructor NewX. Canton-era codebases follow this religiously.
nil is not one thing. Different type families have different nil semantics. Slices and maps behave differently under nil — maps panic on write.nil from a function returning an interface type.