Go's Nil Map Asymmetry: The Read That Smiles While the Write Panics

2026-05-27

This program loads a server config from JSON and adds a request-ID header before serving. The JSON file looks fine, the loader returns no error, even the first header lookup prints cleanly. Then the program crashes with a panic on a line that is doing the most ordinary thing imaginable.

type ServerConfig struct {
    Host    string            `json:"host"`
    Port    int               `json:"port"`
    Headers map[string]string `json:"headers"`
}

func loadConfig(path string) (*ServerConfig, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    cfg := &ServerConfig{}
    if err := json.Unmarshal(data, cfg); err != nil {
        return nil, err
    }
    return cfg, nil
}

func main() {
    // server.json contains: {"host": "api.example.com", "port": 443}
    cfg, err := loadConfig("server.json")
    if err != nil {
        log.Fatal(err)
    }

    fmt.Println("existing X-Auth:", cfg.Headers["X-Auth"]) // prints "", no panic
    cfg.Headers["X-Request-ID"] = newRequestID()           // panic here
    log.Fatal(http.ListenAndServe(":8080", handler(cfg)))
}

The Bug

The JSON file has no "headers" key. json.Unmarshal doesn't fabricate fields that aren't present — it leaves cfg.Headers at its zero value, which for a map is nil. So far, so reasonable.

The cruel part is Go's deliberately asymmetric behavior around nil maps:

This asymmetry is what turns the bug from "obvious nil deref" into "ticking time bomb." The diagnostic line — fmt.Println(cfg.Headers["X-Auth"]) — that a careful programmer would write to confirm the map looks healthy will always succeed, regardless of whether the map is nil or empty. The map looks empty, behaves empty, and reports empty length. Only the first write reveals that "empty" and "nil" are not the same animal.

It's especially nasty because the panic site has no syntactic clue. cfg.Headers["X-Request-ID"] = ... is the kind of line that gets reviewed in two seconds. The nil-ness was introduced thousands of bytes earlier, in a deserialization step that didn't even know Headers existed.

The Fix

After unmarshaling, ensure any map fields you intend to write to are non-nil. Either initialize them eagerly in your loader, or give the type a UnmarshalJSON that does it:

func loadConfig(path string) (*ServerConfig, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    cfg := &ServerConfig{}
    if err := json.Unmarshal(data, cfg); err != nil {
        return nil, err
    }
    if cfg.Headers == nil {
        cfg.Headers = make(map[string]string)
    }
    return cfg, nil
}

A defensive alternative at the call site is maps.Insert patterns or a small helper like setHeader(cfg, k, v) that lazily allocates. Slices avoid this trap because append on a nil slice allocates, but maps have no equivalent — there is no mapAppend, only direct subscript assignment, and that requires a backing table.

Key Takeaway: In Go, a nil map reads silently like an empty one but panics on every write — so always initialize map fields after deserialization, because absent JSON keys leave them nil.

All newsletters