Go ·
Go variable not updating inside an if block (:= shadowing)
loadConfig read the file, then returned the default with a nil error. A := inside the if block made a second variable. The fix, plus the vet shadow flag.
Test yourself before reading - the write-up below gives it away.
package main
import "fmt"
var files = map[string]string{"prod.cfg": "port=9090"}// Load the config, falling back to the default when no path is set.
func loadConfig(path string) (string, error) {cfg := "port=8080"
if path != "" {cfg, ok := files[path]
if !ok { return cfg, fmt.Errorf("missing %s", path)}
}
return cfg, nil
}
func main() { fmt.Println(loadConfig("prod.cfg"))// expected: port=9090 <nil>, actual: port=8080 <nil> (!)
}
"The file says port=9090," the ticket insisted, with a screenshot. It did. The loader found that file, read it, and hit no error path, and the service came up on 8080 anyway, in dev, in staging, and then in prod. Between the successful read and the return statement, the value vanished.
Here is the loader, a default plus an optional config file:
package main
import "fmt"
var files = map[string]string{"prod.cfg": "port=9090"}
// Load the config, falling back to the default when no path is set.
func loadConfig(path string) (string, error) {
cfg := "port=8080"
if path != "" {
cfg, ok := files[path]
if !ok {
return cfg, fmt.Errorf("missing %s", path)
}
}
return cfg, nil
}
func main() {
fmt.Println(loadConfig("prod.cfg"))
// expected: port=9090 <nil>, actual: port=8080 <nil> (!)
}
Why does my Go variable keep its old value after an if block?
Line 11 is the bug. := declares new variables, so cfg, ok := files[path] creates a second cfg that exists only inside the if block, shadowing the outer one. The lookup result lands in the inner cfg and dies at the closing brace on line 15; the return on line 16 sees the untouched outer cfg. Rename the temporary and assign: val, ok := files[path], then cfg = val.
The comma-ok line practically writes the shadow for you
Go's declarations and scope rules say an identifier declared inside a block denotes a new entity within it, and an inner declaration may reuse an outer name. That is ordinary lexical scoping, and in most code it is invisible. What arms it here is the comma-ok idiom: you want the map value and the found-flag, ok does not exist yet, and := is the only operator that can introduce it in one line. So the natural, idiomatic way to write line 11 is the way that also re-declares cfg. The compiler has no complaint, because both inner variables are genuinely used, on lines 12 and 13. As of Go 1.26, plain go vet passes this file without a word; on this snippet it reports nothing.
The two lines that look dangerous are innocent. The map literal on line 5 is correct, and the fmt.Errorf on line 13 formats a fine error that, note, returns the inner cfg legitimately. Even the error path works. That is what keeps the bug alive in a test suite: call loadConfig("nope.cfg") and you get the right error; call loadConfig("") and you get the right default. The only path that misbehaves is total success, and the failure it produces is a valid-looking value with a nil error, which no assertion on error handling will ever trip.
Two braces, one variable, three environments
Play the ticket forward. The 8080 default works on every developer laptop, because dev machines run the default on purpose. Staging gets a config file, comes up on 8080 anyway, and nobody notices because staging sits behind a load balancer that health-checks whatever port answers. Production gets the same file and the same 8080, and the reverse proxy, pointed at 9090, starts throwing connection refused. The instinct is to debug the file: permissions, encoding, the deploy step that copies it. Every one of those checks passes, because the file really is read. The bug is eight characters of scope, and the log line that would have exposed it, printing cfg right before the return, is the one debug print nobody adds because "we already know it read the file."
The fix: rename the temporary, or predeclare ok
The rename is one line and keeps the flow:
if path != "" {
val, ok := files[path]
if !ok {
return cfg, fmt.Errorf("missing %s", path)
}
cfg = val
}
cfg = val is a plain assignment to the outer variable, so the value now survives the brace; the fixed program prints port=9090 <nil>. If you prefer keeping the name, predeclare the flag: var ok bool above the if, then cfg, ok = files[path] assigns both without declaring anything.
There is also a fix that teaches you the wrong lesson. Change := to = on line 11 alone and the compiler objects, undefined: ok, because nothing declared ok. A reasonable person reads that error, concludes the := was required after all, reverts, and walks away convinced the original line was correct. That compile error is the bug's best defense: it punishes the naive fix and rewards the buggy original. The way out is to accept that one line cannot both declare ok and assign to the outer cfg; something has to give, and it should be the temporary's name.
Worth knowing: the Go team was asked to change this. Proposal #65700, make := always shadow, sits in the Go 2 pile with years of debate behind it; the semantics you just debugged are the semantics Go is keeping. The 1.22 release fixed loop variables and left shadowing exactly as is.
Turn the shadow analyzer on before the next one
The check exists; it is off by default. Install the standalone analyzer and point vet at it:
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/shadow@latest
go vet -vettool=$(which shadow) ./...
On this file it flags line 11: declaration of "cfg" shadows declaration at line 9. In golangci-lint, the same analyzer hides inside govet: enable govet with shadow: true in your config. Expect noise, because plenty of shadows are harmless (err in a one-line if err := f(); err != nil never leaks), so triage hits by one question: does the outer variable get read after the block? For a quick manual pass, grep the suspicious shape of a known outer name being redeclared: grep -rnE '^\s+\w+, (ok|err) :=' --include=*.go . and check each hit's left-hand name against the enclosing function.
Related bugs
Scope deciding more than logic is a Go house specialty: closures in a loop sharing one captured variable is the pre-1.22 cousin where the variable was too shared instead of too new. And a nil that is not what it looks like drives the typed-nil error interface bug, another case of a correct-looking return carrying the wrong thing. More on the Go bug hub, everything else at bug write-ups.
The ticket closed with a one-word diff: val. The screenshot of the config file had been right the whole time.