The zero value contract
You declare a variable. var buf bytes.Buffer. You call buf.WriteByte('x'). It works. No setup. No NewBuffer(). No configuration. The variable is ready the moment it exists.
This is the zero value idiom. Go types should be usable immediately after declaration. If a type requires initialization, the type itself should handle that initialization lazily, not the caller.
Go has no constructors. new(T) allocates memory and returns a pointer to zeroed bytes. It does not run code. If your type needs internal state, you have two choices: force the user to call a constructor function, or make the zero value useful by initializing state on first use. The idiom strongly prefers the second choice.
Think of a hotel room. A useful zero value is a room that is clean, has a made bed, and working lights the moment you walk in. You do not need to call maintenance to assemble the furniture. You might need to turn on the TV, but the room itself is ready. A broken zero value is a room where the door is locked and the bed is in pieces. You cannot use it until someone fixes it.
Lazy initialization in action
The standard library models this behavior everywhere. bytes.Buffer, strings.Builder, sync.Mutex, and http.Client all have useful zero values. You can declare them with var, pass them around, and call methods without panicking.
Consider a simple logger. A naive design requires a constructor:
type Logger struct {
out io.Writer
}
func NewLogger(w io.Writer) *Logger {
return &Logger{out: w}
}
This breaks the zero value idiom. If someone writes var l Logger and calls l.Log("hello"), the program crashes because l.out is nil. The type forces the caller to remember to call NewLogger. That is friction.
The idiomatic version initializes lazily:
// Logger provides a simple logging interface.
// The zero value is ready to use; it logs to stdout.
type Logger struct {
mu sync.Mutex
out io.Writer
}
// Log writes a message to the configured output.
// It defaults to stdout if no output is set.
func (l *Logger) Log(msg string) {
l.mu.Lock()
defer l.mu.Unlock()
// Lazy init: check if out is nil.
// If nil, use os.Stdout as the default.
// This allows var l Logger to work immediately.
w := l.out
if w == nil {
w = os.Stdout
}
fmt.Fprintln(w, msg)
}
The receiver name l matches the type Logger. This is the convention: one or two letters, lowercase. The mutex mu is part of the struct. The zero value of sync.Mutex is valid and unlocked. The zero value of io.Writer is nil. The Log method checks for nil and provides a sensible default. The caller never sees the initialization logic.
Zero values are a contract. Honor it.
Bridging Go and C libraries
Sometimes you wrap a C library that demands explicit initialization. C functions often require you to call an init routine before using a struct. Go wants zero values. The solution is an internal flag that tracks initialization state.
The gmp package demonstrates this pattern. The underlying C type mpz_t must be initialized with mpz_init before use. The Go wrapper hides this requirement:
// Int wraps a C mpz_t for arbitrary precision arithmetic.
// The zero value represents zero and is safe to use.
type Int struct {
i C.mpz_t
init bool
}
// doinit ensures the underlying C struct is initialized.
// It is idempotent and safe to call multiple times.
func (z *Int) doinit() {
// Check the flag to avoid redundant C calls.
// The init flag is part of the struct, so it is zeroed on declaration.
if z.init {
return
}
z.init = true
C.mpz_init(&z.i[0])
}
// Set sets z to the value of x.
func (z *Int) Set(x *Int) *Int {
z.doinit()
x.doinit()
C.mpz_set(z.i[0], x.i[0])
return z
}
The init boolean is false in the zero value. The first method call runs doinit, which sets the flag and calls the C function. Subsequent calls see the flag and skip the C call. The Go user writes var x Int and calls x.Set(y) without knowing about C initialization.
Trust gofmt. The indentation and spacing are decided by the tool. Do not argue about formatting; focus on the logic. Most editors run gofmt on save, so the code stays consistent automatically.
Lazy init bridges gaps. It does not fix bad design.
Thread-safe lazy init
Lazy initialization introduces a risk. If multiple goroutines call a method simultaneously, they might both see the uninitialized state and try to initialize at the same time. This causes data races or double initialization.
The sync package provides sync.Once for this exact problem. Once guarantees a function runs exactly once, even under concurrent calls.
// Cache stores string values with lazy initialization.
// The zero value is safe for concurrent use.
type Cache struct {
data map[string]string
once sync.Once
}
// Get retrieves a value from the cache.
// It initializes the map on first access.
func (c *Cache) Get(key string) string {
// once.Do ensures the map is created exactly once.
// Multiple goroutines can call Get safely.
c.once.Do(func() {
c.data = make(map[string]string)
})
return c.data[key]
}
The once field is part of the struct. The zero value of sync.Once is valid. The first call to Get runs the closure and creates the map. All other calls skip the closure. The map access is safe because the map exists.
If you need to initialize multiple fields or perform complex setup, wrap the logic in a method and call it via once.Do. Do not roll your own locking with boolean flags unless you have measured that sync.Once is too slow for your specific bottleneck. sync.Once is highly optimized.
The worst goroutine bug is the one that never logs. Use sync.Once to protect lazy state.
Pitfalls and compiler errors
The zero value idiom fails when the type cannot have a sensible default. If a struct requires a database connection, a file path, or a secret key, the zero value cannot be useful. In those cases, a constructor is necessary.
Forcing a zero value where it does not belong leads to runtime panics. If you access a nil map, the runtime panics with panic: assignment to entry in nil map. If you call a method on a nil pointer, you get panic: runtime error: invalid memory address or nil pointer dereference. The compiler does not catch these errors. They appear at runtime.
Consider a struct with a required configuration:
type Client struct {
baseURL string
token string
}
The zero value has an empty baseURL and empty token. If DoRequest uses these fields, it sends requests to an empty URL with no authentication. This is not useful. This type needs a constructor:
func NewClient(baseURL, token string) *Client {
if baseURL == "" {
panic("baseURL is required")
}
return &Client{baseURL: baseURL, token: token}
}
Here, the constructor validates inputs. The zero value is intentionally not useful. This is acceptable when the type cannot function without external parameters.
Another pitfall is forgetting to handle the zero value in methods. If a method assumes a field is initialized, it will panic. Always check for nil or use lazy init. The compiler cannot help you here. You must write the checks.
If you forget to import a package, the compiler rejects the program with undefined: pkg. If you import a package and do not use it, you get imported and not used. These are compile-time errors. Zero value panics are runtime errors. They are harder to find. Write tests that use the zero value explicitly.
Don't fight the type system. Wrap the value or change the design.
Decision: zero value vs alternatives
Choosing between zero value idioms, constructors, and other patterns depends on the type's requirements. Use the pattern that matches the complexity and constraints of the type.
Use the zero value idiom when the type has sensible defaults and lazy initialization is cheap. Use lazy initialization when the setup cost is low or can be deferred until first use. Use sync.Once when the type must be safe for concurrent access.
Use a constructor function when the type requires validation or external resources that cannot be deferred. Use a constructor when the zero value would be dangerous or meaningless. Use a constructor when you need to return an error during creation.
Use functional options when the type has many optional parameters but a stable core state. Use functional options when you want to keep the constructor signature simple while allowing configuration.
Use a struct literal when the caller needs to specify exact state at creation time and the zero value is insufficient. Use a struct literal when the type is simple and all fields are optional.
Use plain sequential code when you don't need concurrency: the simplest thing that works is usually the right thing.