A Go function returns a nil error even though an inner if block set err — how do you diagnose and prevent this class of bug?
answer
- a zero value means nobody wrote to it
- look for the brace, not the name
- one-character experiment: := becomes =
- the failure branch has no test
- the shadow pass is not in vet by default
basics
~20 sThe inner block's := declared a second err that died at the closing brace, so the returned variable kept its nil zero value. Confirm by changing := to =; prevent it with the x/tools shadow analyzer and a test on the failure path.
solid answer
~50 sRead the inner block for `:=`: a short declaration inside a nested block creates a *new* variable, so the assignment never reached the one being returned, which still holds its zero value. The quickest confirmation is to change `err :=` to `err =` — if it compiles, the outer variable was in scope all along and was being shadowed; if it does not, there was no outer variable and the logic is different. Reproduce it with a test that forces the failure branch, since the bug is invisible on the happy path — that missing test is usually the real defect. To catch the class rather than the instance, run the `shadow` analyzer from `golang.org/x/tools/go/analysis/passes/shadow` in CI; it is not one of `go vet`'s default checks, so it must be installed and invoked deliberately, and it is a heuristic that only reports when a same-named, same-typed outer variable is used after the inner declaration.
code
go · 14 linesfunc apply(hunks []hunk) error {
var err error
for _, h := range hunks {
if h.reversed {
err := writeReversed(h) // := declares a new err inside this block
if err != nil {
continue // the outer err never sees this failure
}
} else {
err = writeHunk(h)
}
}
return err
}go deeper
Recognise the shape: a short declaration inside a nested block makes a second variable, so the outer one keeps its zero value. Being able to point at the brace that causes it is what is expected here.
Explain the mechanics and the confirming experiment — swap := for = and see whether it still compiles — and know that the shadow check lives in golang.org/x/tools rather than in a default vet run.
Show the full loop: reproduce with a test on the failure path, fix the scope rather than the symptom, and get the analyzer into CI knowing it is a heuristic with real false negatives. Name the code shapes that stop producing the bug.
Own the cost tradeoff of enabling a noisy analyzer across an existing codebase: the triage bill, where you set the gate, and why a blanket ban on shadowing would cost more in rejected idiomatic code than it saves.
## What actually happened A short variable declaration declares into the innermost enclosing block. Inside an `if`, a `for` body or a `select` case, `err := doSomething()` therefore creates a *second* variable named `err` whose life ends at the closing brace. The outer variable — the one the function returns — was never written, so it still holds its zero value, `nil`. Take a patch tool that applies hunks to a file: ```go func apply(hunks []hunk) error { var err error for _, h := range hunks { if h.reversed { err := writeReversed(h) // := declares a new err in this block if err != nil { continue // the outer err never sees this failure } } else { err = writeHunk(h) } } return err } ``` Every reversed hunk that fails is silently dropped, and the tool reports a clean apply over a file it did not finish patching. Nothing in the build complains: shadowing is legal, and the unused-variable rule is satisfied because the inner `err` is checked inside its own block. ## Diagnosing the instance 1. **Localise by value, not by control flow.** The returned value is a zero value, which means the returning variable was never assigned on the path taken. Look for every write to that name, and check which block each one lives in. 2. **Look for the brace, not the name.** The two declarations often read identically; what differs is that one is inside a nested block. Scanning for `:=` in blocks *below* the declaration you care about is the fastest visual sweep. 3. **Flip `:=` to `=`.** This is the decisive one-character experiment. If the file still compiles, an outer variable of a compatible type was in scope, and the original code was shadowing it. If the compiler now says the variable is undefined, no outer variable existed and you are looking at a different bug. 4. **Write the failing test first.** The bug survives because the failure branch is never exercised. A test that forces the inner call to fail and asserts a non-nil error both reproduces the defect and stops it returning. ## Catching the class The purpose-built tool is the `shadow` analyzer that ships in `golang.org/x/tools/go/analysis/passes/shadow`. Two things about it decide how you use it: - **It is not in `go vet`'s default checks.** A plain `go vet ./...` will not report shadowing; the analyzer has to be installed and run explicitly (its `cmd/shadow` command can be handed to `go vet` through `-vettool`). Teams that assume vet covers it are quietly running with no coverage at all. - **It is deliberately conservative.** It reports an inner declaration only when a variable of the same name *and same type* exists in an outer scope and is referenced *after* the inner declaration — a heuristic chosen because reporting every legal shadow would drown a real codebase in noise. So it catches the shape above (the outer `err` is used by the `return`), and misses variants where the outer variable is only read before the block. Treat it as a net with known holes, not a proof. Expect a first run on an existing codebase to produce a mix of true findings and idiomatic-but-flagged code; triage it once, fix the real ones, and then wire it into CI so the count stays at zero. Adding it to a green build is far cheaper than adding it to a red one. ## Preventing it structurally Tooling is the backstop; the code shapes matter more: - **Declare in the narrowest scope that works.** Most accidental shadows exist because an outer variable was hoisted further out than the code needed. If the value is only interesting inside the block, there is no outer variable to shadow. - **Do not accumulate a result across branches when you can act on it immediately.** The `var err error` at the top of a long loop is the shape most prone to this; handling each failure where it happens removes the outer variable entirely. - **Give different values different names.** When an inner error genuinely is a different thing, `writeErr` or `parseErr` says so, compiles to the same code, and makes the review question disappear. - **Read for it in review.** As the reviewer of a pull request, a `:=` inside a block for a name that also exists outside is worth one question every time. This is one of the few Go defects a reviewer can spot faster than a tool, because the reviewer knows which value is supposed to survive the block. ## What to say about it The honest framing is that shadowing is a feature with a sharp edge, not a language flaw to be eliminated. Inner scopes hiding outer names is what makes small blocks safe to write. The bug is not that Go allows it; it is that the same name is being used for two values with different lifetimes, and the fix — at the instance level and at the codebase level — is to stop doing that where it matters.
- Why does the shadow analyzer not report every shadowed variable?Because most shadowing is legal and intentional, and reporting all of it would be unusable. The analyzer applies a heuristic: it flags an inner declaration only when an outer variable of the same name and type is referenced after the inner one is declared, which is the shape that usually indicates a mistake. That trades false positives for known false negatives.
- What does it tell you if changing an inner `err :=` to `err =` stops the file compiling?That no outer err was in scope, so nothing was being shadowed and your diagnosis is wrong. The behaviour you are chasing has another cause — an ignored return, a wrong branch, an error swallowed by a helper. The experiment is useful precisely because both outcomes are informative.
- How would you stop this class of bug reaching main in a team codebase?Three layers: run the shadow analyzer in CI after a one-time triage so new findings block the build; require a test on the failure path for any change that returns an error, since that is what would have caught it; and treat an inner short declaration of a name that already exists as a standing review question. The first two are automatic and the third catches what the heuristic misses.
- Is banning shadowing outright a reasonable policy?No. Shadowing is how small blocks stay independent, and a blanket ban would reject a great deal of idiomatic code, including init clauses and per-case declarations. The useful rule is narrower: do not reuse a name for a second value when the first one has to survive the block.
saying these in an interview costs you the question
- Blames the error-wrapping helper instead of reading the scopes
- Assumes go vet already reports shadowed variables
- Says the compiler should have caught the unused inner variable
- Proposes banning all shadowing in the codebase
- Fixes the one line and adds no test for the failure branch
- Treats a clean shadow-analyzer run as proof no shadowing remains