Why does `if err := step(); err != nil` leave an outer `err` variable nil in Go?
answer
- colon-equals declares, it never assigns out
- the if statement opens its own block
- two variables, one name
- the outer one was never written to
- one character: = instead of :=
basics
~10 sThe short variable declaration := creates a brand-new err inside the if statement's own scope. Nothing written there reaches the outer variable, so a later return err reports nil even though a call failed.
solid answer
~50 s`:=` always declares in the current block. Inside an `if` statement it declares an `err` scoped to that statement and its branches, which shadows the outer `err` of the same name; assignments to the inner one never touch the outer one. So a function that declares `var err error` at the top, writes `if err := step(); err != nil { break }` inside a loop, and later does `return err` returns `nil` forever — the failure was recorded in a variable that no longer exists. The compiler is silent because both variables are used and shadowing is legal Go. Two fixes: handle the failure entirely inside the `if` (return or assign to a differently named outer variable), or drop the `:=` and use `=` against the already-declared outer `err`. The compact `if err := f(); err != nil` form is idiomatic precisely when you finish with the error inside the block.
code
go · 10 linesfunc (m *Migrator) applyAll(steps []Step) error {
var err error
for _, s := range steps {
if err := s.Apply(); err != nil { // declares a new err
break
}
m.record(s.Version)
}
return err // the outer err was never assigned: always nil
}go deeper
Know the difference between := and = and that := inside an if or for block makes a new variable. Being able to point at the shadowed err in a snippet is enough at this level.
Explain the scoping rule precisely: := declares in the innermost block, the if init clause opens one, and the same-block reuse rule never crosses a boundary. Then show both correct rewrites.
Talk about catching it before it ships: the review habit of checking which operator produced any err consumed outside its block, a regression test that pins the invariant, and bisecting to the refactor that flipped one character.
Frame it as a class rather than a bug: decide the codebase convention that removes it — errors are handled or returned in the block that produces them — and be able to say what you would spend on tooling versus review to enforce it.
## What `:=` actually does A short variable declaration **declares**. It does not assign to something that already exists elsewhere. Inside a block, `x := v` creates a new `x` in that block; if an `x` is already visible from an enclosing block, the new one shadows it for the rest of the inner block, and the outer one is untouched and unreachable by name. The one nuance people half-remember is the same-block rule: `a, b := f()` may reuse `a` if `a` was already declared **in that same block** and at least one name on the left is new. That rule never crosses a block boundary, and an `if` statement opens a new one. So this is a fresh variable, always: ```go var err error if err := step(); err != nil { // new err, scoped to the if ... } return err // the outer err, still nil ``` The scope of a variable declared in an `if` statement's init clause covers the condition, the `if` body, and any `else` branches — and stops there. ## The failure this produces Consider a schema migration tool that applies ordered steps and records each applied version: ```go func (m *Migrator) applyAll(steps []Step) error { var err error for _, s := range steps { if err := s.Apply(); err != nil { // shadows break } m.record(s.Version) } return err // always nil } ``` The behaviour on a bad day: step 7 fails, the loop breaks, and `applyAll` returns `nil`. The caller sees a clean migration run and reports success. Worse variants omit the `break` and keep going, so the tool records every version as applied while one of them never ran, and the schema and the version table disagree from then on. Nothing crashed, no log line was necessarily emitted, and the damage is discovered by the next deploy that assumes the schema is at version N. This is the bug an engineer arriving from a language where `err = ...` inside a block simply assigns is most likely to write. In those languages there is no declaration syntax that quietly re-declares; in Go the two operators look nearly identical and one of them creates a new variable. ## Why nothing warns you The compiler rejects *unused* variables, not shadowed ones. Both `err` variables are used here — the inner one in the condition, the outer one in the `return` — so the program is valid Go and builds without a word. Shadowing is a deliberate and useful feature: it is what makes `if v, ok := m[k]; ok` and `if err := f(); err != nil` safe to write repeatedly in one function without name collisions. The language will not tell you when a shadow was accidental, because it cannot know. ## Finding it after the fact When the symptom is "a failed step was recorded as applied", the fastest route is usually a test that pins the invariant — inject a step that always fails, assert that `applyAll` returns non-nil and that nothing after it was recorded — and then run `git bisect` with that test as the predicate to land on the commit where the behaviour changed. The commit is normally a small refactor: someone moved a call into an `if` init clause, or extracted a loop body, and a `=` became a `:=` on the way. ## The two correct shapes **Finish inside the block.** This is the idiomatic form and the reason the compact syntax exists. The error is created, tested and disposed of without ever needing to outlive the `if`: ```go for _, s := range steps { if err := s.Apply(); err != nil { return err } m.record(s.Version) } return nil ``` **Or assign, do not declare.** If the error genuinely has to survive the block — you want to break out and do cleanup before returning — declare it once and use `=`: ```go var err error for _, s := range steps { if err = s.Apply(); err != nil { break } m.record(s.Version) } return err ``` The difference is one character, which is exactly why this is worth being able to spot on sight in a review. A useful reading habit: whenever an `err` is consumed further away than the block that produced it, check which operator produced it. ## The general rule In Go, `:=` declares in the innermost block; `=` assigns to whatever is visible. Any time both an inner and an outer variable share a name, decide deliberately which one you meant. For error handling the safest default is to never let an `err` cross a block boundary at all: handle it or return it where it is produced, and the question of which variable you wrote to stops existing.
- Why does the compiler not reject this, given it rejects unused variables?Both variables are used — the inner one in the condition, the outer one in the return — so the program is well formed. Shadowing is a legal and useful feature: it is what lets `if err := f(); err != nil` appear five times in one function. The compiler cannot know which shadow was an accident.
- When is the compact `if err := f(); err != nil` form the right choice?Whenever the error is handled or returned inside that block, which is most of the time. Scoping `err` to the statement keeps it from being reused later by mistake and keeps the name free. It becomes dangerous only when someone expects the value to be readable after the block.
- How would you locate the commit that introduced a silent skip like this?Write a test that injects a step which always fails and asserts the function returns a non-nil error and records nothing afterwards. Then run `git bisect` with that test as the predicate. The offending commit is usually a small refactor where a `=` became a `:=` while a call moved into an `if` init clause.
saying these in an interview costs you the question
- Says := assigns to the outer variable of the same name
- Thinks the inner err is visible after the if block
- Claims the compiler warns about shadowed variables
- Believes only loops can hide the shadowing
- Fixes it by renaming instead of choosing = or :=