Go's Value Philosophy: Part 3 - Zero Values: Go's Valid-by-Default Philosophy

Deep dive into Go's zero values: how declaration creates valid values, why Go has no uninitialized state, and how this eliminates entire classes of bugs that plague null-based languages.

📚 Series: Go Value Philosophy

  1. Go's Value Philosophy: Part 1 - Why Everything Is a Value, Not an Object
  2. Go's Value Philosophy: Part 2 - Escape Analysis and Performance
  3. Go's Value Philosophy: Part 3 - Zero Values: Go's Valid-by-Default Philosophy (current)

In Part 1 , we explored Go’s value philosophy and how it differs from Python’s objects and Java’s classes. Part 2 revealed how escape analysis makes value semantics performant. Now we address a fundamental question about value semantics:

What happens when you declare a variable?

1
2
var x int
fmt.Println(x)  // What is x?

In Python, undeclared variables don’t exist (NameError). In Java, local variables must be assigned before use (compile error). In Go, x exists immediately as the value 0 - the zero value for integers.

Declaration vs initialization:

  • Declaration: Announcing a variable exists and reserving memory for it
  • Initialization: Giving that variable its first value
flowchart LR subgraph other["Most Languages"] d1[Declaration
Reserve memory] --> i1[Uninitialized state] --> a1[Assignment
First value] end subgraph go["Go"] d2[Declaration
Reserve memory] --> i2[Zero value
Immediate value] end style other fill:#4C4538,stroke:#6b7280,color:#f0f0f0 style go fill:#3A4C43,stroke:#6b7280,color:#f0f0f0

In most languages, these are separate steps. You declare a variable (reserve space), then initialize it (give it a value).

Go merges these: declaration IS initialization. When you declare var x int, you don’t get uninitialized memory - you get the integer 0.

Every Go variable is initialized at the moment it’s declared.

Zero Values: Go’s Valid-by-Default Philosophy

In Go, declaration creates a value. When you write var x int, you don’t get an uninitialized variable - you get the integer 0. This is the zero value for integers.

Every type in Go has a zero value - the state a variable holds from the moment it’s declared. No null, no undefined, no uninitialized memory. Declaration equals instantiation.

This simple design choice eliminates entire classes of bugs and enables API designs impossible in languages where variables can be uninitialized or null.


Declaration Creates Values

The fundamental difference between Go and other languages is what happens when you declare a variable:

Go: Declaration creates a valid value

1
2
3
4
5
6
7
8
var x int     // x now holds the value 0
var s string  // s now holds the value "" (empty string)
var b bool    // b now holds the value false

// These are usable immediately:
fmt.Println(x)  // 0
fmt.Println(s)  // ""
fmt.Println(b)  // false

Python: Assignment creates variables

1
2
3
4
5
6
# Python has no "var x" or "let x" syntax
# Assignment creates the variable
x = 0     # Declaration and initialization happen together
print(x)  # 0

# Can't declare without a value - no equivalent to "var x int"

Java: Local variables forbidden before assignment

1
2
3
4
5
6
7
void method() {
    int x;  // Declared but has no value
    System.out.println(x);  // COMPILE ERROR: variable might not have been initialized
    
    x = 0;  // Now has value
    System.out.println(x);  // 0
}
The key distinction: Go has no concept of “uninitialized variables.” Declaration creates a value in memory. The zero value IS the value, not a placeholder for a future value.

The Nil Paradox: Valid by Default?

Wait - if Go is “valid by default,” why does nil exist?

Go’s zero value philosophy has a nuance: some types have nil as their zero value (pointers, slices, maps, channels, interfaces, functions). But nil in Go is different from null in Java or None in Python.

Go’s nil is safe for reading:

1
2
3
4
5
6
7
var s []int        // nil slice
fmt.Println(len(s))  // 0 (safe!)
s = append(s, 1)     // Works! (allocates)

var m map[string]int  // nil map
fmt.Println(m["key"]) // 0 (safe!)
// m["key"] = 1       // PANIC (must initialize for writes)

Java’s null crashes on any operation:

1
2
3
4
5
6
7
8
String s = null;
System.out.println(s.length());  // NullPointerException!

Map<String, Integer> m = null;
int value = m.get("key");  // NullPointerException!

List<String> list = null;
int size = list.size();  // NullPointerException!

NullPointerException is the most common production error in Java. Every null reference is a landmine waiting to explode.

The difference: Go’s nil values have well-defined, safe behavior for reading:

  • Nil slices can be read (length 0) and appended to
  • Nil maps can be read (returns zero value) but not written to
  • Methods can be called on nil receivers (if the method handles it)

In Go, collections (slices, maps) can be nil and still safely queried. In Java, any operation on a null collection crashes. This eliminates the majority of null-related production errors.

“Valid by default” means: Every declared variable can be safely used in some way. For value types (int, bool, string, struct), all operations work. For types with nil zero values (pointers, slices, maps, channels), read operations work - only mutation requires initialization.

What Are Zero Values?

Zero value is the value a variable holds from the moment it’s declared. It’s not “default” or “initial” - it’s simply what the value IS until you assign something else.

For most types, the zero value supports all operations. For types whose zero value is nil (pointers, slices, maps, channels, interfaces, functions), read operations work but write operations may require initialization.

Built-in Types

TypeZero ValueUsability
boolfalseImmediately usable
int, int8, int16, int32, int640Immediately usable
uint, uint8, uint16, uint32, uint640Immediately usable
float32, float640.0Immediately usable
string"" (empty string)Immediately usable
pointernilSafe to check, unsafe to dereference
slicenilSafe to read (length 0), can append
mapnilSafe to read, must initialize to write
channelnilBlocks forever on operations
interfacenilSafe to check, unsafe to call methods
functionnilSafe to check, unsafe to call

Example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
var (
    b bool       // false
    i int        // 0
    f float64    // 0.0
    s string     // ""
    p *int       // nil
    slice []int  // nil
    m map[string]int  // nil
)

fmt.Println(b)  // false
fmt.Println(i)  // 0
fmt.Println(f)  // 0
fmt.Println(s)  // "" (empty, but valid string)
fmt.Println(len(slice))  // 0 (nil slice has length 0)

Contrast with Other Languages

Python: No Default Values

Python requires explicit initialization or raises NameError:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# Error: name 'x' is not defined
print(x)

# Must initialize explicitly
x = 0
print(x)  # 0

# Class fields default to None (not zero!)
class Counter:
    pass

c = Counter()
print(c.count)  # AttributeError: no attribute 'count'

# Must initialize in __init__
class Counter:
    def __init__(self):
        self.count = 0  # Explicit initialization required

Java: Split Behavior

Java has different rules for local variables vs fields:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class Example {
    int field;  // Defaults to 0 (field)
    
    void method() {
        int local;  // ERROR: must be initialized before use
        // System.out.println(local);  // Compile error
        
        int initialized = 0;  // Must explicitly initialize
        System.out.println(initialized);  // OK
    }
    
    void useField() {
        System.out.println(field);  // OK - fields default to 0
    }
}

Java objects default to null:

1
2
3
4
5
6
7
public class Container {
    String name;  // Defaults to null (not empty string!)
    
    void print() {
        System.out.println(name.length());  // NullPointerException!
    }
}

Go: Consistent Zero Values

Go applies zero values uniformly:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// All valid immediately
var x int        // 0
var s string     // ""
var found bool   // false

func process() {
    var count int  // 0 (no initialization needed)
    count++
    fmt.Println(count)  // 1
}

type Config struct {
    Timeout int
    Retries int
}

var c Config  // {Timeout: 0, Retries: 0}
// Immediately usable, no nil checks needed

Why Zero Values Matter

1. Eliminate Null Pointer Exceptions

Java’s null problem:

1
2
3
4
5
6
7
String name = null;  // Common default
System.out.println(name.toUpperCase());  // NullPointerException!

// Must check everywhere
if (name != null) {
    System.out.println(name.toUpperCase());
}

Go’s zero value solution:

1
2
3
4
var name string  // "" (empty string, not nil)
fmt.Println(strings.ToUpper(name))  // "" (works fine, no panic)

// No nil checks needed for value types

The pattern: Some types have nil as their zero value: pointers, slices, maps, channels, interfaces, and functions. Other types (int, bool, string, structs) are never nil - they always have concrete zero values.

2. Simpler Struct Initialization

Python requires boilerplate:

1
2
3
4
5
6
7
8
class Config:
    def __init__(self):
        self.timeout = 30  # Must explicitly initialize
        self.retries = 3
        self.enabled = True
        self.prefix = ""
        
c = Config()  # Must call __init__

Go’s zero values reduce boilerplate:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
type Config struct {
    Timeout int     // 0 by default
    Retries int     // 0 by default
    Enabled bool    // false by default
    Prefix  string  // "" by default
}

// Zero value struct is valid
var c Config  // {Timeout: 0, Retries: 0, Enabled: false, Prefix: ""}

// Override only what you need
c2 := Config{
    Timeout: 30,
    Retries: 3,
    Enabled: true,
}
// Prefix stays "" (zero value)

3. Enable “Ready to Use” Types

Zero values enable types that work without explicit initialization:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// sync.Mutex: zero value is ready to use
var mu sync.Mutex
mu.Lock()   // Works immediately!
mu.Unlock()

// bytes.Buffer: zero value is ready to use
var buf bytes.Buffer
buf.WriteString("hello")  // Works immediately!
fmt.Println(buf.String())  // "hello"

// strings.Builder: zero value is ready to use
var sb strings.Builder
sb.WriteString("world")  // Works immediately!

Compare to Java:

1
2
3
// Must explicitly construct
StringBuilder sb = new StringBuilder();  // Must initialize
sb.append("hello");

The Nil Exception

Not all zero values are non-nil. Some types have nil as their zero value:

Types with nil zero values:

  • Pointers: *T
  • Slices: []T
  • Maps: map[K]V
  • Channels: chan T
  • Interfaces: interface{}
  • Functions: func()

Safe Nil Behavior

Go’s nil has predictable behavior:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Nil slice: safe to read, can append
var s []int  // nil
fmt.Println(len(s))  // 0 (safe)
s = append(s, 1)     // Works! (allocates backing array)

// Nil map: safe to read, panics on write
var m map[string]int  // nil
fmt.Println(m["key"])  // 0 (safe, returns zero value)
m["key"] = 1          // PANIC! Must initialize first

// Must initialize maps explicitly
m = make(map[string]int)
m["key"] = 1  // Works

Nil Receivers

Methods can be called on nil receivers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type Tree struct {
    value int
    left  *Tree
    right *Tree
}

func (t *Tree) Sum() int {
    if t == nil {  // Nil check
        return 0
    }
    return t.value + t.left.Sum() + t.right.Sum()
}

var tree *Tree  // nil
sum := tree.Sum()  // Works! Returns 0

This pattern is impossible in Java (NullPointerException) and Python (AttributeError).


Struct Zero Values: Composition

Struct zero values are the zero values of their fields:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
type Point struct {
    X, Y int
}

type Line struct {
    Start Point
    End   Point
}

var line Line
// line = Line{
//     Start: Point{X: 0, Y: 0},
//     End:   Point{X: 0, Y: 0},
// }

fmt.Println(line.Start.X)  // 0

Nested structs compose their zero values:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
type Config struct {
    Server   ServerConfig
    Database DatabaseConfig
}

type ServerConfig struct {
    Port    int
    Timeout int
}

type DatabaseConfig struct {
    MaxConns int
    IdleTime int
}

var cfg Config
// All fields recursively zero-valued:
// cfg.Server.Port = 0
// cfg.Server.Timeout = 0
// cfg.Database.MaxConns = 0
// cfg.Database.IdleTime = 0

Designing for Zero Values

Pattern 1: Zero Value is Ready to Use

Design types so their zero value is immediately functional:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Good: Zero value works
type Cache struct {
    mu    sync.RWMutex
    items map[string][]byte  // nil is fine
}

func (c *Cache) Get(key string) ([]byte, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    
    val, ok := c.items[key]  // nil map returns zero value
    return val, ok
}

func (c *Cache) Set(key string, val []byte) {
    c.mu.Lock()
    defer c.mu.Unlock()
    
    if c.items == nil {  // Lazy initialization
        c.items = make(map[string][]byte)
    }
    c.items[key] = val
}

// Usage: zero value works
var cache Cache  // No New() function needed
cache.Set("key", []byte("value"))

Pattern 2: Constructor for Complex Setup

When zero value isn’t sufficient, provide a constructor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Server struct {
    addr    string
    handler http.Handler
    logger  *log.Logger
}

// Zero value isn't useful (no address, no handler)
func NewServer(addr string, handler http.Handler) *Server {
    return &Server{
        addr:    addr,
        handler: handler,
        logger:  log.Default(),  // Provide defaults
    }
}

Pattern 3: Validate in Methods

Defer initialization until first use:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
type DB struct {
    conn *sql.DB
}

func (db *DB) Query(query string) (*sql.Rows, error) {
    if db.conn == nil {
        return nil, errors.New("database not connected")
    }
    return db.conn.Query(query)
}

func (db *DB) Connect(dsn string) error {
    conn, err := sql.Open("postgres", dsn)
    if err != nil {
        return err
    }
    db.conn = conn
    return nil
}

Comparison: Initialization Patterns

Python: Explicit Initialization Required

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class Buffer:
    def __init__(self):
        self.data = []  # Must initialize
        self.size = 0
        
    def append(self, item):
        self.data.append(item)
        self.size += 1

# Must call constructor
buf = Buffer()  # __init__ runs
buf.append(42)

Java: Constructors or Null

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class Buffer {
    private List<Integer> data;  // null by default
    
    public Buffer() {
        this.data = new ArrayList<>();  // Must initialize
    }
    
    public void append(int item) {
        if (data == null) {  // Defensive check
            data = new ArrayList<>();
        }
        data.add(item);
    }
}

// Must construct
Buffer buf = new Buffer();
buf.append(42);

Go: Zero Value Composability

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
type Buffer struct {
    data []int  // nil slice (zero value)
    size int    // 0 (zero value)
}

func (b *Buffer) Append(item int) {
    b.data = append(b.data, item)  // append works on nil slice
    b.size++
}

// Zero value works
var buf Buffer  // No constructor needed
buf.Append(42)  // Just works

Zero Values and Memory Safety

Zero values make Go’s memory model predictable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Cache struct {
    data map[string]string  // nil
    mu   sync.RWMutex       // Zero value ready
}

// Zero value is safe (won't panic, won't corrupt memory)
var c Cache
c.mu.Lock()    // Works (zero value mutex is valid)
_ = c.data["x"]  // Returns "" (nil map returns zero value)
c.mu.Unlock()

// Only writes need initialization
c.data = make(map[string]string)
c.data["x"] = "value"  // Now writes work

Contrast with C (uninitialized memory):

1
2
3
4
5
int x;  // Contains garbage (whatever was in memory)
printf("%d\n", x);  // Undefined behavior!

// Must explicitly initialize
int y = 0;

Contrast with Java (null references):

1
2
3
4
5
String name;  // null
System.out.println(name.length());  // NullPointerException!

// Must check or initialize
String name = "";

Go guarantees: Variables are always initialized to their zero value. No uninitialized memory, no accidental null dereferences for value types.


When Zero Values Don’t Suffice

Not all types can be useful with zero values:

Requires Configuration

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type Client struct {
    endpoint string
    apiKey   string
    timeout  time.Duration
}

// Zero value not useful (no endpoint, no API key)
func NewClient(endpoint, apiKey string) *Client {
    return &Client{
        endpoint: endpoint,
        apiKey:   apiKey,
        timeout:  30 * time.Second,  // Sensible default
    }
}

Requires External Resources

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
type Database struct {
    conn *sql.DB  // nil (requires connection)
}

// Can't provide zero value for external resource
func Open(dsn string) (*Database, error) {
    conn, err := sql.Open("postgres", dsn)
    if err != nil {
        return nil, err
    }
    return &Database{conn: conn}, nil
}

Requires Validation

1
2
3
4
5
6
7
8
9
type Email string

// Zero value ("") is technically valid but semantically wrong
func NewEmail(addr string) (Email, error) {
    if !strings.Contains(addr, "@") {
        return "", errors.New("invalid email")
    }
    return Email(addr), nil
}

The Standard Library’s Approach

Go’s standard library demonstrates zero value design:

sync.Mutex: Zero Value Ready

1
2
3
4
5
6
7
8
9
type Counter struct {
    mu    sync.Mutex  // Zero value works
    count int
}

var c Counter  // No initialization needed
c.mu.Lock()
c.count++
c.mu.Unlock()

bytes.Buffer: Zero Value Ready

1
2
3
var buf bytes.Buffer  // Zero value ready
buf.WriteString("hello")
fmt.Println(buf.String())  // "hello"

http.Server: Constructor Required

1
2
3
4
5
6
// Zero value not useful (no handler, no address)
server := &http.Server{
    Addr:    ":8080",
    Handler: mux,
}
server.ListenAndServe()

The pattern: If a type can be useful with zero values, make it so. If it requires configuration or external resources, provide a constructor (New* function).


Putting It Together

Go’s zero value philosophy stems directly from its value model. In languages where variables are references to objects, uninitialized variables either error (Python) or hold null (Java). In Go, where variables are values, uninitialized variables hold the zero value of their type.

This creates a programming model where declaration equals initialization. No separate steps, no null checks for value types, no uninitialized memory. Every variable is immediately valid and safe to use, even if not explicitly initialized.

The tradeoffs:

Python’s explicit initialization prevents accidentally using uninitialized state but requires boilerplate constructors. Java’s null defaults enable lazy initialization but introduce null pointer exceptions. Go’s zero values provide safety and simplicity but require thoughtful API design to ensure zero values are actually useful.

The mental model: In Go, absence of explicit initialization doesn’t mean “uninitialized” or “null.” It means “initialized to the most reasonable default for this type.” This shifts error handling from defensive nil checks to validating business logic instead.


Further Reading

Go Initialization:

Related Posts:


Next in Series

Part 4: Slices, Maps, and Channels - The Hybrid Types - Coming soon. Learn why these types look like values but behave like references, and how this affects your code.

📚 Series: Go Value Philosophy

  1. Go's Value Philosophy: Part 1 - Why Everything Is a Value, Not an Object
  2. Go's Value Philosophy: Part 2 - Escape Analysis and Performance
  3. Go's Value Philosophy: Part 3 - Zero Values: Go's Valid-by-Default Philosophy (current)