The Short Variable Declaration Shadow Gotcha in Go

The short variable declaration `:=` creates a new variable that shadows outer variables, leaving the original unchanged.

The silent shadow

You write a loop to accumulate a total. You print the total after the loop finishes. The value is zero. You stare at the screen. The loop ran. The math is correct. The variable name is identical. Go compiled the program without errors. The compiler didn't warn you. The value just didn't change.

This is the shadow gotcha. You used := inside the loop instead of =. Go created a new variable inside the loop body that hides the outer variable. The outer variable sits there, untouched, while the inner one does all the work and vanishes when the loop ends. The compiler allows this because shadowing is sometimes useful. It treats the inner variable as a completely separate entity. You end up with a ghost variable that runs, updates, and dies without affecting the state you actually care about.

How scope and declaration work

Go variables live in blocks. A block starts with { and ends with }. Functions are blocks. Loop bodies are blocks. if and switch bodies are blocks. When you declare a variable, it belongs to the innermost block that contains the declaration.

The short variable declaration := does two things. It declares a variable and assigns a value. The tricky part is the declaration rule. := looks for variables with the names on the left side in the current scope. If a name exists in the current scope, := assigns to it. If a name does not exist in the current scope, := declares a new variable for that name.

Shadowing happens when you use := in an inner block with a name that exists in an outer block. The inner block is a new scope. The name does not exist in the inner scope yet. := declares a new variable in the inner scope. This new variable has the same name as the outer one. The inner variable shadows the outer one. You can no longer access the outer variable from inside the inner block. When the inner block ends, the inner variable is discarded. The outer variable reappears, unchanged.

Think of a folder inside a folder with the same name. You open the inner folder. You see the files inside. You don't see the files in the outer folder. When you close the inner folder, the outer folder is there. Nothing in the outer folder changed.

Shadowing is silent. The compiler trusts you.

The minimal trap

Here's the simplest case. A counter that refuses to increment because the loop creates a local copy.

package main

import "fmt"

func main() {
    // Outer total starts at zero.
    total := 0

    for i := 0; i < 3; i++ {
        // This := creates a NEW 'total' inside the loop scope.
        // It shadows the outer 'total'.
        // The outer 'total' is never touched.
        total := i * 10
        fmt.Println("Inner total:", total)
    }

    // Outer total is still zero.
    fmt.Println("Outer total:", total)
}
# output:
Inner total: 0
Inner total: 10
Inner total: 20
Outer total: 0

The compiler sees total := 0 and creates total in the main function scope. Inside the loop, it sees total := i * 10. It looks for total in the loop scope. It doesn't find one. It creates a new total in the loop scope. This new variable hides the outer one. The loop updates the inner variable. When the loop iteration ends, the inner variable dies. The outer variable remains zero.

The fix is to use = when the variable already exists in the scope you want to update. The assignment operator = never declares a new variable. It only assigns. If the variable doesn't exist, the compiler rejects the program with undefined: total. This error saves you. The shadow gotcha happens because := is too helpful. It creates the variable instead of failing.

:= creates. = updates. Know the difference.

Real code gets bitten

Real code often hides this in longer functions. You declare a variable at the top, do some work, and use it later. If you accidentally shadow it in the middle, the later use gets the wrong value. This is especially painful when the shadowed variable is used for accumulation or state tracking.

Here's a function that calculates a sum. The shadowing bug makes it return zero every time.

package main

import "fmt"

// calculateSum adds values from a slice.
// It demonstrates a subtle shadowing bug.
func calculateSum(values []int) int {
    // sum accumulates the result.
    sum := 0

    for _, v := range values {
        // BUG: This := shadows 'sum'.
        // It creates a local 'sum' for this iteration.
        // The outer 'sum' never changes.
        sum := sum + v
        fmt.Println("Step sum:", sum)
    }

    // Returns the untouched outer 'sum'.
    return sum
}

func main() {
    result := calculateSum([]int{10, 20, 30})
    fmt.Println("Final result:", result)
}
# output:
Step sum: 10
Step sum: 20
Step sum: 30
Final result: 0

The inner sum is initialized with sum + v. The right-hand side sum refers to the outer sum because the inner sum hasn't been declared yet on the left-hand side. The inner sum gets the value of the outer sum plus v. Then the inner sum is discarded. The outer sum remains zero. The print statement shows the math working, which makes the bug harder to spot. The return value is wrong.

A shadowed variable is a ghost. It exists, it runs, but it leaves no trace.

The multi-variable twist

The short declaration has a special rule for multiple variables. If you use := with multiple names, Go assigns to any names that already exist in the current scope and declares the rest. This is why if err := do(); err != nil works. It allows mixing assignment and declaration in one statement.

This rule only applies within the same scope. It does not prevent shadowing across scope boundaries.

package main

import "fmt"

func main() {
    // x exists in this scope.
    x := 10

    // This := assigns to x and declares y.
    // It does NOT shadow x because x is in the same scope.
    x, y := 20, 30
    fmt.Println(x, y)
}
# output:
20 30

The compiler sees x, y := 20, 30. It checks the current scope. x exists. y does not. It assigns 20 to x and declares y with 30. No shadowing occurs. This is the intended behavior. It makes code concise. You can update some variables and declare others in one line.

Shadowing only happens when you cross a block boundary. If you put x, y := 20, 30 inside an if block, x would be shadowed because the if block is a new scope. The multi-variable rule doesn't reach into outer scopes.

Use := for new vars. Use = for updates.

Pitfalls and tools

The compiler does not warn about shadowing. It considers shadowing a valid program. If you shadow a variable and use the inner one, the code compiles and runs. The logic is wrong, but the syntax is fine.

If you declare a variable and never use it, the compiler rejects the program with declared and not used. Shadowing can trigger this error if you create a variable inside a block and forget to reference it. This error helps catch accidental declarations. It doesn't catch shadowing where you use the inner variable.

Go developers rely on linters to catch shadowing. Tools like staticcheck and revive include checks for variable shadowing. staticcheck flags shadowing with a warning. Many teams run linters in continuous integration pipelines. The linter catches the bug before it reaches production.

Running a linter is part of the Go workflow. gofmt formats your code. go vet checks for suspicious constructs. Linters check for style and common bugs. You should run them locally and in CI. Don't rely on go build for logic correctness. go build checks syntax and types. It doesn't check if you shadowed a variable by mistake.

The community convention for error handling leans on scoping. The pattern if err := do(); err != nil { return err } declares err inside the if block. This scopes err to the error path. It prevents err from leaking into the happy path. If you need to return err, you assign it to an outer variable. This shows the design intent of scoping. Scoping keeps variables close to where they are used. It reduces accidental state leakage.

Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Scoping err in the if statement reinforces this. The error variable lives only where the error is handled.

Linters are your safety net. Run them.

Decision matrix

Use := when you are introducing a brand new variable in the current scope. Use = when you want to update an existing variable from an outer scope. Use a linter like staticcheck when you want the toolchain to warn you about accidental shadowing. Use explicit naming when a variable name is reused across scopes to avoid confusion. Use var declaration when you need to separate declaration from assignment or specify a type explicitly.

Trust the linter. Write clear code.

Where to go next