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 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:
m[k] returns the zero value of the value type. len(m) returns 0. for k, v := range m simply runs zero iterations. No panic, no warning.assignment to entry in nil map. There is no allocation-on-demand; the runtime needs a hash table to insert into, and there isn't one.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.
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.
